Compare commits
3
Commits
1922f87dd2
...
ca671d6453
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca671d6453 | ||
|
|
a522424491 | ||
|
|
b1ddec1b8a |
No files matched your search
@@ -93,6 +93,7 @@ Bin/*
|
|||||||
nul
|
nul
|
||||||
server/go/web/assets/index.html
|
server/go/web/assets/index.html
|
||||||
server/go/users.json
|
server/go/users.json
|
||||||
|
server/go/devices.json
|
||||||
server/go/build/
|
server/go/build/
|
||||||
server/go/.claude/settings.json
|
server/go/.claude/settings.json
|
||||||
android/app/.cxx/
|
android/app/.cxx/
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <shlobj.h>
|
#include <shlobj.h>
|
||||||
#include <set>
|
#include <set>
|
||||||
|
#include <deque>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
// Algorithm constants (same as ScreenSpyDlg.cpp)
|
// Algorithm constants (same as ScreenSpyDlg.cpp)
|
||||||
#define ALGORITHM_H264 2
|
#define ALGORITHM_H264 2
|
||||||
@@ -66,6 +68,165 @@ static std::string ParseReqField(const Json::Value& root) {
|
|||||||
return "";
|
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)
|
// Challenge-response nonce storage (prevents replay attacks)
|
||||||
static std::map<void*, std::string> s_ClientNonces;
|
static std::map<void*, std::string> s_ClientNonces;
|
||||||
static std::mutex s_NonceMutex;
|
static std::mutex s_NonceMutex;
|
||||||
@@ -362,9 +523,12 @@ void CWebService::ServerThread(int port) {
|
|||||||
// 缺少这些字段的服务端(Go / 旧 C++ 二进制)会让新前端自动降级。
|
// 缺少这些字段的服务端(Go / 旧 C++ 二进制)会让新前端自动降级。
|
||||||
challenge["caps"]["multiWatch"] = true;
|
challenge["caps"]["multiWatch"] = true;
|
||||||
challenge["caps"]["keyframeRequest"] = true;
|
challenge["caps"]["keyframeRequest"] = true;
|
||||||
|
challenge["caps"]["auditLog"] = true;
|
||||||
Json::StreamWriterBuilder builder;
|
Json::StreamWriterBuilder builder;
|
||||||
builder["indentation"] = "";
|
builder["indentation"] = "";
|
||||||
SendText(ws_ptr, Json::writeString(builder, challenge));
|
SendText(ws_ptr, Json::writeString(builder, challenge));
|
||||||
|
|
||||||
|
AuditWeb("web.ws.connect", false, "", "", conn->clientIP(), ws_ptr, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// WebSocket disconnect
|
// WebSocket disconnect
|
||||||
@@ -443,6 +607,8 @@ void CWebService::ServerThread(int port) {
|
|||||||
HandleTermResize(ws_ptr, msg);
|
HandleTermResize(ws_ptr, msg);
|
||||||
} else if (cmd == "term_close") {
|
} else if (cmd == "term_close") {
|
||||||
HandleTermClose(ws_ptr, msg);
|
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)
|
// Verify nonce matches (prevents replay)
|
||||||
std::string storedNonce = ConsumeNonce(ws_ptr);
|
std::string storedNonce = ConsumeNonce(ws_ptr);
|
||||||
if (storedNonce.empty() || storedNonce != clientNonce) {
|
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"));
|
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid or expired challenge"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check rate limit
|
// Check rate limit
|
||||||
if (!CheckRateLimit(client_ip)) {
|
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."));
|
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Too many failed attempts. Try again later."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -554,6 +735,8 @@ void CWebService::HandleLogin(void* ws_ptr, const std::string& msg, const std::s
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
RecordFailedLogin(client_ip);
|
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"));
|
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid credentials"));
|
||||||
return;
|
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);
|
std::string expectedResponse = WSAuth::ComputeSHA256(user->password_hash + clientNonce);
|
||||||
if (response != expectedResponse) {
|
if (response != expectedResponse) {
|
||||||
RecordFailedLogin(client_ip);
|
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"));
|
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid credentials"));
|
||||||
return;
|
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
|
// Build response
|
||||||
Json::Value res;
|
Json::Value res;
|
||||||
res["cmd"] = "login_result";
|
res["cmd"] = "login_result";
|
||||||
@@ -628,6 +815,24 @@ void CWebService::HandleGetSalt(void* ws_ptr, const std::string& msg) {
|
|||||||
if (!userFound) {
|
if (!userFound) {
|
||||||
// Generate deterministic fake salt from username (won't match any real password)
|
// Generate deterministic fake salt from username (won't match any real password)
|
||||||
salt = WSAuth::ComputeSHA256("fake_salt_prefix_" + username).substr(0, 16);
|
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;
|
Json::Value res;
|
||||||
@@ -703,6 +908,8 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
|
AuditWeb("remote.screen.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
|
||||||
|
{{"reason", "group_permission"}});
|
||||||
send_error("Permission denied");
|
send_error("Permission denied");
|
||||||
return;
|
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
|
// remote_open, cleared on session teardown), so AI input cannot be injected into a
|
||||||
// picture a human is watching/controlling.
|
// picture a human is watching/controlling.
|
||||||
if (IsMcpTriggered(device_id)) {
|
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");
|
send_error("Device busy: remote control session active");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -731,6 +940,8 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
|
|||||||
// Check max clients per device
|
// Check max clients per device
|
||||||
int current_count = GetWebClientCount(device_id);
|
int current_count = GetWebClientCount(device_id);
|
||||||
if (!already && current_count >= m_nMaxClientsPerDevice) {
|
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");
|
send_error("Too many viewers");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -760,12 +971,19 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (too_many_watch) {
|
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");
|
send_error("Too many devices on one connection");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// StopRemoteDesktop 会再次锁 m_ClientsMutex,必须在锁外调用。
|
// StopRemoteDesktop 会再次锁 m_ClientsMutex,必须在锁外调用。
|
||||||
for (uint64_t id : to_stop) {
|
if (!to_stop.empty()) {
|
||||||
StopRemoteDesktop(id);
|
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. 若之前已订阅
|
// 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);
|
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");
|
send_error("Failed to start remote desktop");
|
||||||
return;
|
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)
|
// Get screen dimensions + audio state from device info cache (may not be ready)
|
||||||
int width = 0, height = 0;
|
int width = 0, height = 0;
|
||||||
int audio_enabled = -1; // -1 = unknown yet (前端走 audio_state 事件兜底)
|
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) {
|
for (uint64_t id : to_stop) {
|
||||||
StopRemoteDesktop(id);
|
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, "");
|
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);
|
WideCharToMultiByte(936, 0, wide.c_str(), wlen, (char*)szData.data() + 1, glen, nullptr, nullptr);
|
||||||
|
|
||||||
ctx->Send2Client(szData.data(), (ULONG)szData.size());
|
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) {
|
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 回包
|
// 单字节命令,客户端以 SendClientClipboard → TOKEN_CLIPBOARD_TEXT 回包
|
||||||
BYTE bToken = COMMAND_SCREEN_GET_CLIPBOARD;
|
BYTE bToken = COMMAND_SCREEN_GET_CLIPBOARD;
|
||||||
ctx->Send2Client(&bToken, sizeof(BYTE));
|
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) {
|
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;
|
BYTE bToken = CMD_RESTORE_CONSOLE;
|
||||||
if (ctx->Send2Client(&bToken, 1)) {
|
if (ctx->Send2Client(&bToken, 1)) {
|
||||||
Mprintf("[WebService] Sent RDP reset command to device %llu\n", device_id);
|
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));
|
SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", true));
|
||||||
} else {
|
} 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"));
|
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
|
// Only admin can create users
|
||||||
if (role != "admin") {
|
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"));
|
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Permission denied"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1281,7 +1527,12 @@ void CWebService::HandleCreateUser(void* ws_ptr, const std::string& msg) {
|
|||||||
return;
|
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));
|
SendText(ws_ptr, BuildJsonResponse("create_user_result", true));
|
||||||
} else {
|
} else {
|
||||||
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Failed to create user (may already exist)"));
|
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
|
// Only admin can delete users
|
||||||
if (role != "admin") {
|
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"));
|
SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Permission denied"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string targetUsername = root.get("username", "").asString();
|
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));
|
SendText(ws_ptr, BuildJsonResponse("delete_user_result", true));
|
||||||
} else {
|
} else {
|
||||||
SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Failed to delete user"));
|
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
|
// Only admin can list users
|
||||||
if (role != "admin") {
|
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"));
|
SendText(ws_ptr, BuildJsonResponse("list_users_result", false, "Permission denied"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1370,6 +1628,8 @@ void CWebService::HandleGetGroups(void* ws_ptr, const std::string& token) {
|
|||||||
|
|
||||||
// Only admin can get groups list (for user management)
|
// Only admin can get groups list (for user management)
|
||||||
if (role != "admin") {
|
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"));
|
SendText(ws_ptr, BuildJsonResponse("groups", false, "Permission denied"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1403,6 +1663,132 @@ void CWebService::HandleGetGroups(void* ws_ptr, const std::string& token) {
|
|||||||
SendText(ws_ptr, json);
|
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)
|
// 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) {
|
void CWebService::UnregisterClient(void* ws_ptr) {
|
||||||
std::set<uint64_t> watched;
|
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.
|
// 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);
|
auto it = m_Clients.find(ws_ptr);
|
||||||
if (it != m_Clients.end()) {
|
if (it != m_Clients.end()) {
|
||||||
watched = std::move(it->second.watch_device_ids);
|
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);
|
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.
|
// StopRemoteDesktop re-locks m_ClientsMutex, so this must run outside the lock.
|
||||||
for (uint64_t device_id : watched) {
|
for (uint64_t device_id : watched) {
|
||||||
StopRemoteDesktop(device_id);
|
StopRemoteDesktop(device_id);
|
||||||
|
AuditWeb("remote.screen.stop", false, username, role, ip, ws_ptr, device_id,
|
||||||
|
{{"reason", "ws_close"}});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭这个 web client 持有的所有终端会话(MVP 一个用户一台主机一个,但兜底全扫描)
|
// 关闭这个 web client 持有的所有终端会话(MVP 一个用户一台主机一个,但兜底全扫描)
|
||||||
@@ -1454,7 +1848,17 @@ void CWebService::UnregisterClient(void* ws_ptr) {
|
|||||||
for (auto& kv : m_TermSessions) {
|
for (auto& kv : m_TermSessions) {
|
||||||
if (kv.second.ws_ptr == ws_ptr) to_close.push_back(kv.first);
|
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);
|
context* ctx = m_pParentDlg->FindHost(device_id);
|
||||||
if (!ctx) {
|
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"));
|
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Device offline"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2139,6 +2545,8 @@ void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!ok) {
|
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"));
|
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Permission denied"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2149,6 +2557,8 @@ void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) {
|
|||||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||||
if (m_TermSessions.find(device_id) != m_TermSessions.end() ||
|
if (m_TermSessions.find(device_id) != m_TermSessions.end() ||
|
||||||
m_TermPending.find(device_id) != m_TermPending.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,
|
SendText(ws_ptr, BuildJsonResponse("term_closed", false,
|
||||||
"Terminal already open by another viewer"));
|
"Terminal already open by another viewer"));
|
||||||
return;
|
return;
|
||||||
@@ -2162,13 +2572,18 @@ void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) {
|
|||||||
// 触发客户端:发 COMMAND_SHELL
|
// 触发客户端:发 COMMAND_SHELL
|
||||||
BYTE cmd = COMMAND_SHELL;
|
BYTE cmd = COMMAND_SHELL;
|
||||||
if (!ctx->Send2Client(&cmd, 1)) {
|
if (!ctx->Send2Client(&cmd, 1)) {
|
||||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
{
|
||||||
m_TermSessions.erase(device_id);
|
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||||
m_TermPending.erase(device_id);
|
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"));
|
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Send failed"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Mprintf("[WebService] term_open device=%llu user=%s\n", device_id, username.c_str());
|
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) {
|
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);
|
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||||
auto it = m_TermSessions.find(device_id);
|
auto it = m_TermSessions.find(device_id);
|
||||||
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
|
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);
|
auto it = m_TermSessions.find(device_id);
|
||||||
if (it == m_TermSessions.end()) return;
|
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"));
|
SendText(ws_ptr, BuildJsonResponse("term_closed", true, "closed"));
|
||||||
Mprintf("[WebService] term_closed device=%llu\n", device_id);
|
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) {
|
bool CWebService::IsTermPending(uint64_t device_id) {
|
||||||
@@ -2329,7 +2760,7 @@ void CWebService::OnTerminalClosed(CONTEXT_OBJECT* ctx) {
|
|||||||
auto it = m_TermContextToDevice.find(ctx);
|
auto it = m_TermContextToDevice.find(ctx);
|
||||||
if (it == m_TermContextToDevice.end()) return;
|
if (it == m_TermContextToDevice.end()) return;
|
||||||
uint64_t device_id = it->second;
|
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
|
// 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()) {
|
if (!offline.empty()) {
|
||||||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||||||
for (auto& [ws_ptr, client] : m_Clients) {
|
for (auto& [ws_ptr, client] : m_Clients) {
|
||||||
@@ -2409,9 +2842,15 @@ void CWebService::FlushDeviceChanges() {
|
|||||||
SendText(ws_ptr, Json::writeString(builder, res));
|
SendText(ws_ptr, Json::writeString(builder, res));
|
||||||
client.watch_device_ids.erase(id);
|
client.watch_device_ids.erase(id);
|
||||||
client.keyframe_req_at.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
|
// Clear offline devices from cache
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <set>
|
#include <set>
|
||||||
|
#include <initializer_list>
|
||||||
|
#include <utility>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -76,6 +78,10 @@ struct WebClient {
|
|||||||
uint64_t connected_at;
|
uint64_t connected_at;
|
||||||
uint64_t last_activity; // Last message/pong received (for heartbeat timeout)
|
uint64_t last_activity; // Last message/pong received (for heartbeat timeout)
|
||||||
std::string client_ip;
|
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) {}
|
WebClient() : connected_at(0), last_activity(0) {}
|
||||||
};
|
};
|
||||||
@@ -239,6 +245,15 @@ private:
|
|||||||
void HandleListUsers(void* ws_ptr, const std::string& token);
|
void HandleListUsers(void* ws_ptr, const std::string& token);
|
||||||
void HandleGetGroups(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
|
// Send to WebSocket
|
||||||
void SendText(void* ws_ptr, const std::string& text);
|
void SendText(void* ws_ptr, const std::string& text);
|
||||||
void SendBinary(void* ws_ptr, const uint8_t* data, size_t len);
|
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::set<uint64_t> m_TermPending; // 已发 COMMAND_SHELL 待响应
|
||||||
std::mutex m_TermMutex;
|
std::mutex m_TermMutex;
|
||||||
|
|
||||||
// 内部清理(已持锁版本)
|
// 内部清理(已持锁版本)。reason/actor/role/ip 仅用于审计日志;actor 为空时
|
||||||
void CloseTermSessionLocked(uint64_t device_id);
|
// 尝试从 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
|
// Global accessor
|
||||||
|
|||||||
+42
-1
@@ -141,7 +141,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
|
|||||||
./simpleremoter-server
|
./simpleremoter-server
|
||||||
```
|
```
|
||||||
|
|
||||||
默认监听 TCP 端口 `6543`(被控设备),HTTP 端口 `8080`(浏览器 Web UI)。日志写到 `logs/server.log`。
|
默认监听 TCP 端口 `6543`(被控设备),HTTP 端口 `8080`(浏览器 Web UI)。日志写到 `logs/server.log`,安全审计记录另写 `logs/audit.log`。
|
||||||
|
|
||||||
### 命令行参数
|
### 命令行参数
|
||||||
|
|
||||||
@@ -170,6 +170,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
|
|||||||
| `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` |
|
| `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` |
|
||||||
| `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` |
|
| `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` |
|
||||||
| `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` |
|
| `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` |
|
||||||
|
| `YAMA_AUDIT_LOG` | 安全审计日志路径(JSON lines)。默认 `logs/audit.log`;设为 `off` / `none` / `0` / `-` 关闭独立文件(审计记录仍会写入 `logs/server.log`)。审计记录带 `audit=true` 和稳定的 `event` 名,便于告警与检索。 | `/var/log/yama/audit.log` |
|
||||||
| `YAMA_SSO_JWT_SECRET` | **[SSO]** Shared HS256 secret for bridging the parent portal's `auth_token` JWT into a YAMA web session via `/sso`. Must equal the upstream portal's signing key (FMS: `SERVER_PRIVATE_KEY`). **Only `admin` portal users are auto-logged-in**; other roles still need YAMA's own web login. Unset → SSO disabled, `/sso` returns 401. | `<shared-secret>` |
|
| `YAMA_SSO_JWT_SECRET` | **[SSO]** Shared HS256 secret for bridging the parent portal's `auth_token` JWT into a YAMA web session via `/sso`. Must equal the upstream portal's signing key (FMS: `SERVER_PRIVATE_KEY`). **Only `admin` portal users are auto-logged-in**; other roles still need YAMA's own web login. Unset → SSO disabled, `/sso` returns 401. | `<shared-secret>` |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -327,6 +328,46 @@ func main() {
|
|||||||
{"level":"debug","module":"Handler","time":"2025-12-19T13:17:47+01:00","message":"Heartbeat from client 1 (DESKTOP-BI6RGEJ)"}
|
{"level":"debug","module":"Handler","time":"2025-12-19T13:17:47+01:00","message":"Heartbeat from client 1 (DESKTOP-BI6RGEJ)"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 安全审计日志
|
||||||
|
|
||||||
|
Web 端与设备端的认证、授权、远程控制操作会额外写入**独立**的 `logs/audit.log`(可由 `YAMA_AUDIT_LOG` 改路径或关闭),同时镜像到 `logs/server.log`。每行是一个 JSON 对象,固定带 `audit=true` 与 `event`,其余字段按事件携带 `actor` / `role` / `ip` / `peer_ip` / `conn` / `device_id` / `reason` 等。
|
||||||
|
|
||||||
|
网页暴露到公网后,优先关注这些事件:
|
||||||
|
|
||||||
|
| 事件 | 含义 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| `web.ws.connect` / `web.ws.reject` | WebSocket 建立 / 被拒(Origin 不允许、升级失败) |
|
||||||
|
| `web.login.success` / `web.login.failure` / `web.login.throttled` | 登录成功、失败(含 `reason`、`user_exists`)、触发限流 |
|
||||||
|
| `web.salt.unknown_user` | `get_salt` 探测不存在的用户名(用户名枚举信号,每连接记一次) |
|
||||||
|
| `web.auth.invalid_token` / `web.authz.denied` | 无效 token、权限不足(非 admin 调用管理命令) |
|
||||||
|
| `web.session.resume` | 浏览器用缓存的 token 在重连后恢复会话 |
|
||||||
|
| `web.ws.close` | 连接关闭;带 `duration_ms`、`commands` 以及 `invalid_tokens` / `login_failures` / `admin_denied` 等可疑计数 |
|
||||||
|
| `remote.screen.start` / `.stop` / `.denied` | 远程桌面开始 / 结束(`reason`:disconnect、device_offline、ws_close、replaced)/ 被拒 |
|
||||||
|
| `remote.terminal.start` / `.stop` / `.denied` | 远程终端会话生命周期 |
|
||||||
|
| `remote.clipboard.get` / `.set` | 剪贴板读取 / 写入(只记字节数,**不记内容**) |
|
||||||
|
| `remote.rdp_reset` | 远程 RDP 会话归位操作 |
|
||||||
|
| `admin.user.create` / `admin.user.delete` / `admin.device.delete` | 管理操作(`target` / `device_id` / `mode`) |
|
||||||
|
| `admin.audit.query` | 管理员在网页查询审计日志(记录筛选条件与命中数) |
|
||||||
|
| `device.online` / `device.offline` | 受控设备上线 / 下线 |
|
||||||
|
| `device.auth.success` / `.failure` / `.throttled` / `.heartbeat.success` | 设备 TCP 授权结果(**只记 passcode 长度,不记 passcode**) |
|
||||||
|
| `device.connauth.rejected` / `device.subconn.foreign_ip` | 子连接签名失败 / 子连接来源 IP 与主连接不一致 |
|
||||||
|
|
||||||
|
管理员可以在网页里直接查询:设备页工具栏的 **Audit Log** 按钮(`admin` 角色且服务端在 `caps` 中声明 `auditLog` 时显示)打开审计页面,支持按时间范围、事件(精确值或前缀,如 `web.*`)、actor、IP、device_id、关键字筛选以及“仅警告”、分页。页面读取当前 `logs/audit.log`(超大文件只扫描末尾 32 MB,并会提示;已轮转的历史文件仍用下面的 grep/jq 排查),查询动作本身记为 `admin.audit.query`。
|
||||||
|
|
||||||
|
常用排查命令:
|
||||||
|
```bash
|
||||||
|
# 最近的登录失败与被拒操作
|
||||||
|
grep -E '"event":"web\.(login\.failure|login\.throttled|authz\.denied|salt\.unknown_user)"' logs/audit.log | tail -50
|
||||||
|
|
||||||
|
# 按来源 IP 聚合失败次数(找爆破源)
|
||||||
|
grep '"event":"web.login.failure"' logs/audit.log | jq -r '.ip' | sort | uniq -c | sort -rn | head
|
||||||
|
|
||||||
|
# 谁在什么时候开了远程桌面
|
||||||
|
grep '"event":"remote.screen.start"' logs/audit.log | jq -c '{time,actor,role,ip,device_id}'
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:鼠标 / 键盘事件不逐条记录(频率太高,会把审计日志刷爆);审计以会话粒度(`remote.screen.*`、`remote.terminal.*`)体现操作。审计记录中的字符串字段来自客户端(用户名、User-Agent、Origin、设备名等),已做控制字符清理与长度截断,避免伪造日志行。
|
||||||
|
|
||||||
## 协议格式
|
## 协议格式
|
||||||
|
|
||||||
数据包格式与 C++ 版本兼容:
|
数据包格式与 C++ 版本兼容:
|
||||||
|
|||||||
+121
-17
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/licensing"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/licensing"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||||
@@ -268,7 +269,12 @@ func (h *MyHandler) rejectConnAuth(ctx *connection.Context, status byte) {
|
|||||||
if err := h.srv.Send(ctx, ack); err != nil {
|
if err := h.srv.Send(ctx, ack); err != nil {
|
||||||
h.log.Error("ConnAuth reject send failed for conn=%d: %v", ctx.ID, err)
|
h.log.Error("ConnAuth reject send failed for conn=%d: %v", ctx.ID, err)
|
||||||
}
|
}
|
||||||
h.log.Warn("ConnAuth rejected conn=%d ip=%s status=%d", ctx.ID, ctx.GetPeerIP(), status)
|
h.log.AuditWarn("device.connauth.rejected", map[string]any{
|
||||||
|
"conn": ctx.ID,
|
||||||
|
"ip": ctx.GetPeerIP(),
|
||||||
|
"status": status,
|
||||||
|
"device_id": ctx.GetInfo().ClientID,
|
||||||
|
})
|
||||||
_ = ctx.Close()
|
_ = ctx.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,8 +312,13 @@ func (h *MyHandler) handleTerminalStart(ctx *connection.Context, isPTY bool) {
|
|||||||
// closed term_close already), or a hijack attempt from a source IP
|
// closed term_close already), or a hijack attempt from a source IP
|
||||||
// other than the device's login conn. Log the latter distinctly.
|
// other than the device's login conn. Log the latter distinctly.
|
||||||
if mc := h.hub.MainConn(devID); mc != nil && mc.GetPeerIP() != ctx.GetPeerIP() {
|
if mc := h.hub.MainConn(devID); mc != nil && mc.GetPeerIP() != ctx.GetPeerIP() {
|
||||||
h.log.Warn("terminal sub-conn rejected (foreign source IP): device=%s conn=%d src=%s want=%s",
|
h.log.AuditWarn("device.subconn.foreign_ip", map[string]any{
|
||||||
devID, ctx.ID, ctx.GetPeerIP(), mc.GetPeerIP())
|
"kind": "terminal",
|
||||||
|
"device_id": devID,
|
||||||
|
"conn": ctx.ID,
|
||||||
|
"ip": ctx.GetPeerIP(),
|
||||||
|
"want_ip": mc.GetPeerIP(),
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
h.log.Warn("orphan terminal sub-conn: device=%s conn=%d isPTY=%v",
|
h.log.Warn("orphan terminal sub-conn: device=%s conn=%d isPTY=%v",
|
||||||
devID, ctx.ID, isPTY)
|
devID, ctx.ID, isPTY)
|
||||||
@@ -349,8 +360,13 @@ func (h *MyHandler) handleBitmapInfo(ctx *connection.Context, data []byte) {
|
|||||||
// MasterID from a different source IP than the device's login conn.
|
// MasterID from a different source IP than the device's login conn.
|
||||||
// The latter is a security signal worth surfacing distinctly.
|
// The latter is a security signal worth surfacing distinctly.
|
||||||
if mc := h.hub.MainConn(deviceID); mc != nil && mc.GetPeerIP() != ctx.GetPeerIP() {
|
if mc := h.hub.MainConn(deviceID); mc != nil && mc.GetPeerIP() != ctx.GetPeerIP() {
|
||||||
h.log.Warn("screen sub-conn rejected (foreign source IP): device=%s conn=%d src=%s want=%s",
|
h.log.AuditWarn("device.subconn.foreign_ip", map[string]any{
|
||||||
deviceID, ctx.ID, ctx.GetPeerIP(), mc.GetPeerIP())
|
"kind": "screen",
|
||||||
|
"device_id": deviceID,
|
||||||
|
"conn": ctx.ID,
|
||||||
|
"ip": ctx.GetPeerIP(),
|
||||||
|
"want_ip": mc.GetPeerIP(),
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
h.log.Warn("orphan screen sub-conn %d for unknown device %s; closing", ctx.ID, deviceID)
|
h.log.Warn("orphan screen sub-conn %d for unknown device %s; closing", ctx.ID, deviceID)
|
||||||
}
|
}
|
||||||
@@ -613,7 +629,11 @@ func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) {
|
|||||||
// definitive answer instead of hanging, while a brute-forcer is capped.
|
// definitive answer instead of hanging, while a brute-forcer is capped.
|
||||||
ip := ctx.GetPeerIP()
|
ip := ctx.GetPeerIP()
|
||||||
if !h.loginLimit.Allow(ip) {
|
if !h.loginLimit.Allow(ip) {
|
||||||
h.log.Warn("TOKEN_AUTH rate-limited: ip=%s conn=%d", ip, ctx.ID)
|
h.log.AuditWarn("device.auth.throttled", map[string]any{
|
||||||
|
"conn": ctx.ID,
|
||||||
|
"ip": ip,
|
||||||
|
"reason": "rate_limited",
|
||||||
|
})
|
||||||
resp := h.auth.BuildResponse(&auth.AuthResult{Valid: false, Message: "尝试过于频繁,请稍后再试"})
|
resp := h.auth.BuildResponse(&auth.AuthResult{Valid: false, Message: "尝试过于频繁,请稍后再试"})
|
||||||
if err := h.srv.Send(ctx, resp); err != nil {
|
if err := h.srv.Send(ctx, resp); err != nil {
|
||||||
h.log.Error("Failed to send rate-limit auth response to client %d: %v", ctx.ID, err)
|
h.log.Error("Failed to send rate-limit auth response to client %d: %v", ctx.ID, err)
|
||||||
@@ -629,12 +649,23 @@ func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) {
|
|||||||
authType = "V2"
|
authType = "V2"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device auth records are audit events (not just debug logs): a flood of
|
||||||
|
// failures against one address is how passcode guessing looks. The
|
||||||
|
// submitted passcode is NEVER logged — only its presence and length.
|
||||||
|
fields := map[string]any{
|
||||||
|
"conn": ctx.ID,
|
||||||
|
"ip": ip,
|
||||||
|
"auth_version": authType,
|
||||||
|
"device_id": info.ClientID,
|
||||||
|
"computer": info.ComputerName,
|
||||||
|
"sn": result.SN,
|
||||||
|
"passcode_len": len(result.Passcode),
|
||||||
|
"has_passcode": result.Passcode != "",
|
||||||
|
}
|
||||||
if result.Valid {
|
if result.Valid {
|
||||||
h.log.Info("Auth %s success: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
|
h.log.Audit("device.auth.success", fields)
|
||||||
authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode)
|
|
||||||
} else {
|
} else {
|
||||||
h.log.Warn("Auth %s failed: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
|
h.log.AuditWarn("device.auth.failure", fields)
|
||||||
authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build and send response
|
// Build and send response
|
||||||
@@ -706,15 +737,22 @@ func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) {
|
|||||||
if !ctx.IsAuthorized.Load() {
|
if !ctx.IsAuthorized.Load() {
|
||||||
ctx.IsAuthorized.Store(true)
|
ctx.IsAuthorized.Store(true)
|
||||||
info := ctx.GetInfo()
|
info := ctx.GetInfo()
|
||||||
|
version := "V1"
|
||||||
if authResult.IsV2 {
|
if authResult.IsV2 {
|
||||||
// V2 authorization
|
version = "V2"
|
||||||
h.log.Info("Heartbeat auth V2 success: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
|
|
||||||
info.ClientID, info.ComputerName, ctx.GetPeerIP(), authResult.SN, authResult.Passcode)
|
|
||||||
} else {
|
|
||||||
// V1 authorization
|
|
||||||
h.log.Info("Heartbeat auth V1 success: clientID=%s computer=%s ip=%s sn=%s passcode=%s pwdHmac=%d",
|
|
||||||
info.ClientID, info.ComputerName, ctx.GetPeerIP(), authResult.SN, authResult.Passcode, authResult.PwdHmac)
|
|
||||||
}
|
}
|
||||||
|
// Same redaction rule as TOKEN_AUTH: never log the passcode
|
||||||
|
// or the password HMAC, only that they were supplied.
|
||||||
|
h.log.Audit("device.auth.heartbeat.success", map[string]any{
|
||||||
|
"conn": ctx.ID,
|
||||||
|
"ip": ctx.GetPeerIP(),
|
||||||
|
"auth_version": version,
|
||||||
|
"device_id": info.ClientID,
|
||||||
|
"computer": info.ComputerName,
|
||||||
|
"sn": authResult.SN,
|
||||||
|
"passcode_len": len(authResult.Passcode),
|
||||||
|
"has_pwd_hmac": authResult.PwdHmac != 0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -901,8 +939,30 @@ func main() {
|
|||||||
logCfg.MaxAge = 30 // 30 days
|
logCfg.MaxAge = 30 // 30 days
|
||||||
logCfg.Compress = true
|
logCfg.Compress = true
|
||||||
|
|
||||||
|
// Security audit trail: a separate JSON-lines file so login attempts,
|
||||||
|
// authz denials, remote-control sessions and admin mutations are not
|
||||||
|
// buried in debug output and can be retained / shipped independently.
|
||||||
|
// YAMA_AUDIT_LOG overrides the path; "off"/"none"/"0"/"-" disables the
|
||||||
|
// separate file (records still reach server.log).
|
||||||
|
auditFile := strings.TrimSpace(os.Getenv("YAMA_AUDIT_LOG"))
|
||||||
|
if auditFile == "" {
|
||||||
|
auditFile = "logs/audit.log"
|
||||||
|
}
|
||||||
|
switch strings.ToLower(auditFile) {
|
||||||
|
case "off", "none", "0", "-":
|
||||||
|
auditFile = ""
|
||||||
|
}
|
||||||
|
logCfg.AuditFile = auditFile
|
||||||
|
logCfg.AuditMaxSize = 100
|
||||||
|
logCfg.AuditMaxBackups = 20
|
||||||
|
logCfg.AuditMaxAge = 90
|
||||||
|
logCfg.AuditCompress = true
|
||||||
|
|
||||||
log := logger.New(logCfg)
|
log := logger.New(logCfg)
|
||||||
log.Info("====== Copyright (c) 2026 simpleremoter.com. All rights resvered. ======")
|
log.Info("====== Copyright (c) 2026 simpleremoter.com. All rights resvered. ======")
|
||||||
|
if auditFile != "" {
|
||||||
|
log.Info("Security audit log: %s", auditFile)
|
||||||
|
}
|
||||||
|
|
||||||
// Track env vars where we fell back to a built-in default. Printed once
|
// Track env vars where we fell back to a built-in default. Printed once
|
||||||
// at the end of startup so the operator sees what's in effect — vars the
|
// at the end of startup so the operator sees what's in effect — vars the
|
||||||
@@ -931,6 +991,34 @@ func main() {
|
|||||||
// the HTTP server reads from it.
|
// the HTTP server reads from it.
|
||||||
deviceHub := hub.New()
|
deviceHub := hub.New()
|
||||||
|
|
||||||
|
// Persistent device history: every device that has ever logged in, so the
|
||||||
|
// web UI can keep showing it after it goes offline. Same working-directory
|
||||||
|
// convention as users.json. Retention is capped by count (newest last_seen
|
||||||
|
// wins) so the file and the device_list payload stay bounded.
|
||||||
|
devicesFile := os.Getenv("YAMA_DEVICES_FILE")
|
||||||
|
if devicesFile == "" {
|
||||||
|
devicesFile = "devices.json"
|
||||||
|
rememberDefault("YAMA_DEVICES_FILE", devicesFile)
|
||||||
|
}
|
||||||
|
devicesMax := 1000
|
||||||
|
if v := os.Getenv("YAMA_DEVICES_MAX"); v != "" {
|
||||||
|
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
|
||||||
|
devicesMax = n
|
||||||
|
} else {
|
||||||
|
log.Warn("Invalid YAMA_DEVICES_MAX=%q (want a positive integer); using %d", v, devicesMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deviceHistory, histErr := devicehistory.Open(devicesFile, devicesMax,
|
||||||
|
log.WithPrefix("History"))
|
||||||
|
if histErr != nil {
|
||||||
|
// Keep the store: live recording still works, and the next flush
|
||||||
|
// replaces the unreadable file. Losing old history is preferable to
|
||||||
|
// refusing to start the server.
|
||||||
|
log.Warn("Failed to load %s: %v (starting with empty history)", devicesFile, histErr)
|
||||||
|
}
|
||||||
|
deviceHub.Subscribe(deviceHistory)
|
||||||
|
log.Info("Device history: %s (max %d)", devicesFile, devicesMax)
|
||||||
|
|
||||||
// Build the CMD_MASTERSETTING signer based on env vars:
|
// Build the CMD_MASTERSETTING signer based on env vars:
|
||||||
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
|
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
|
||||||
// HMAC master key lives here)
|
// HMAC master key lives here)
|
||||||
@@ -1154,6 +1242,7 @@ func main() {
|
|||||||
// Start HTTP server for web UI. Hub gives it read-only access to the
|
// Start HTTP server for web UI. Hub gives it read-only access to the
|
||||||
// device registry; the authenticator owns user accounts and session tokens.
|
// device registry; the authenticator owns user accounts and session tokens.
|
||||||
httpSrv := web.New(*httpPort, log.WithPrefix("Web"), deviceHub, webAuth).
|
httpSrv := web.New(*httpPort, log.WithPrefix("Web"), deviceHub, webAuth).
|
||||||
|
WithDeviceHistory(deviceHistory).
|
||||||
WithConfig(webCfg)
|
WithConfig(webCfg)
|
||||||
if err := httpSrv.Start(); err != nil {
|
if err := httpSrv.Start(); err != nil {
|
||||||
log.Fatal("Failed to start HTTP server: %v", err)
|
log.Fatal("Failed to start HTTP server: %v", err)
|
||||||
@@ -1227,5 +1316,20 @@ func main() {
|
|||||||
for _, srv := range servers {
|
for _, srv := range servers {
|
||||||
srv.Stop()
|
srv.Stop()
|
||||||
}
|
}
|
||||||
|
// Devices still connected at shutdown never fire OnDeviceOffline (their
|
||||||
|
// TCP handlers are stopped above), so stamp them now. Otherwise a device
|
||||||
|
// that had been online for days would show a stale "last seen" after the
|
||||||
|
// restart until it reconnects.
|
||||||
|
for _, d := range deviceHub.ListDevices() {
|
||||||
|
deviceHistory.OnDeviceOffline(d.ID)
|
||||||
|
}
|
||||||
|
// Last-chance flush: the debounce timer may hold the final offline
|
||||||
|
// timestamps from devices that dropped while we were shutting down.
|
||||||
|
if err := deviceHistory.Close(); err != nil {
|
||||||
|
log.Warn("device history flush: %v", err)
|
||||||
|
}
|
||||||
log.Info("Server stopped")
|
log.Info("Server stopped")
|
||||||
|
// Flush and release the rotated log handles. Without this the last audit
|
||||||
|
// records can sit in lumberjack's buffered writer on shutdown.
|
||||||
|
_ = log.Close()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
// Package devicehistory persists the set of devices that have ever logged in
|
||||||
|
// to the Go server, so the web UI can keep listing them after they go offline.
|
||||||
|
//
|
||||||
|
// It implements hub.EventHandler and is subscribed to the hub, which means the
|
||||||
|
// only wiring needed at startup is hub.Subscribe(store). Online events upsert
|
||||||
|
// the record (refreshing the display fields the client reports at login) and
|
||||||
|
// offline events merely refresh last_seen.
|
||||||
|
//
|
||||||
|
// Storage is a small JSON file, mirroring the wsauth users.json convention:
|
||||||
|
// atomic temp-file + fsync + rename, loaded once at startup and written back
|
||||||
|
// on a short debounce so a burst of reconnects doesn't rewrite it per device.
|
||||||
|
package devicehistory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// fileVersion lets a future release migrate the on-disk shape. Unknown
|
||||||
|
// versions are still parsed best-effort; only structurally invalid JSON
|
||||||
|
// makes Open fail.
|
||||||
|
fileVersion = 1
|
||||||
|
|
||||||
|
// flushDelay coalesces updates. Device logins/disconnects are sparse, but
|
||||||
|
// a server restart makes every agent reconnect at once; without the
|
||||||
|
// debounce that would be one file write per device.
|
||||||
|
flushDelay = 500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// Record is one persisted device. The fields mirror hub.DeviceInfo so the
|
||||||
|
// offline list can be rendered with the same front-end card code; only the
|
||||||
|
// fields that are stable across sessions are kept (RTT / active window are
|
||||||
|
// live-only and would be stale noise on an offline card).
|
||||||
|
type Record struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Capability string `json:"capability,omitempty"`
|
||||||
|
OS string `json:"os,omitempty"`
|
||||||
|
CPU string `json:"cpu,omitempty"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
InstallTime string `json:"install_time,omitempty"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
PeerIP string `json:"peer_ip,omitempty"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
Screen string `json:"screen,omitempty"`
|
||||||
|
|
||||||
|
FirstSeen int64 `json:"first_seen"`
|
||||||
|
LastSeen int64 `json:"last_seen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileShape struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Devices map[string]*Record `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store is the in-memory device history plus its backing file. Safe for
|
||||||
|
// concurrent use.
|
||||||
|
type Store struct {
|
||||||
|
path string
|
||||||
|
max int // 0 = unlimited
|
||||||
|
log *logger.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
devices map[string]*Record
|
||||||
|
// online is derived from hub events and used only by pruning: a device
|
||||||
|
// that is still connected must keep its record even if its last login is
|
||||||
|
// older than the 1000th offline device's. Not persisted — after a restart
|
||||||
|
// every device is offline until it reconnects.
|
||||||
|
online map[string]bool
|
||||||
|
dirty bool
|
||||||
|
timer *time.Timer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open loads the history at path. A missing file is an empty history, not an
|
||||||
|
// error. If the file exists but cannot be parsed, the returned store is still
|
||||||
|
// usable (empty) along with the error, so callers can log and continue — the
|
||||||
|
// next flush will replace the corrupt file.
|
||||||
|
func Open(path string, max int, log *logger.Logger) (*Store, error) {
|
||||||
|
s := newStore(path, max, log)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
var f fileShape
|
||||||
|
if err := json.Unmarshal(data, &f); err != nil {
|
||||||
|
return s, fmt.Errorf("parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
for id, r := range f.Devices {
|
||||||
|
if r == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The map key is authoritative; tolerate records written by a
|
||||||
|
// future/older shape that omitted or disagreed with the id field.
|
||||||
|
r.ID = id
|
||||||
|
s.devices[id] = r
|
||||||
|
}
|
||||||
|
s.pruneLocked()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns an empty store for path without touching disk. Useful when Open
|
||||||
|
// failed and the caller still wants live recording to work.
|
||||||
|
func New(path string, max int, log *logger.Logger) *Store {
|
||||||
|
return newStore(path, max, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStore(path string, max int, log *logger.Logger) *Store {
|
||||||
|
return &Store{
|
||||||
|
path: path,
|
||||||
|
max: max,
|
||||||
|
log: log,
|
||||||
|
devices: make(map[string]*Record),
|
||||||
|
online: make(map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnDeviceOnline records a device that just logged in. It refreshes every
|
||||||
|
// display field because the agent reports its current name/group/version at
|
||||||
|
// each login, and preserves first_seen across reconnects.
|
||||||
|
func (s *Store) OnDeviceOnline(d hub.DeviceInfo) {
|
||||||
|
s.upsertAt(d, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) upsertAt(d hub.DeviceInfo, at time.Time) {
|
||||||
|
if d.ID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := at.Unix()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
rec, ok := s.devices[d.ID]
|
||||||
|
if !ok {
|
||||||
|
rec = &Record{ID: d.ID, FirstSeen: now}
|
||||||
|
s.devices[d.ID] = rec
|
||||||
|
}
|
||||||
|
if rec.FirstSeen == 0 {
|
||||||
|
rec.FirstSeen = now
|
||||||
|
}
|
||||||
|
rec.Name = d.Name
|
||||||
|
rec.Group = d.Group
|
||||||
|
rec.Version = d.Version
|
||||||
|
rec.Capability = d.Capability
|
||||||
|
rec.OS = d.OS
|
||||||
|
rec.CPU = d.CPU
|
||||||
|
rec.FilePath = d.FilePath
|
||||||
|
rec.InstallTime = d.InstallTime
|
||||||
|
rec.Location = d.Location
|
||||||
|
rec.PeerIP = d.PeerIP
|
||||||
|
rec.IP = d.IP
|
||||||
|
rec.Screen = d.Screen
|
||||||
|
rec.LastSeen = now
|
||||||
|
s.online[d.ID] = true
|
||||||
|
|
||||||
|
s.pruneLocked()
|
||||||
|
s.markDirtyLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnDeviceOffline refreshes last_seen. The record was created when the device
|
||||||
|
// logged in; an unknown ID is ignored (e.g. a device rejected by signing never
|
||||||
|
// reaches the hub).
|
||||||
|
func (s *Store) OnDeviceOffline(id string) {
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.offlineAt(id, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) offlineAt(id string, at time.Time) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.online, id)
|
||||||
|
if rec, ok := s.devices[id]; ok {
|
||||||
|
rec.LastSeen = at.Unix()
|
||||||
|
s.markDirtyLocked()
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The remaining EventHandler methods are not relevant to device history.
|
||||||
|
func (s *Store) OnDeviceUpdate(string, int, string) {}
|
||||||
|
func (s *Store) OnScreenFrame(string, []byte, bool) {}
|
||||||
|
func (s *Store) OnResolutionChange(string, int, int) {}
|
||||||
|
func (s *Store) OnCursorChange(string, byte) {}
|
||||||
|
func (s *Store) OnClipboardText(string, string) {}
|
||||||
|
func (s *Store) OnTerminalReady(string, bool) {}
|
||||||
|
func (s *Store) OnTerminalData(string, []byte) {}
|
||||||
|
func (s *Store) OnTerminalClosed(string, string) {}
|
||||||
|
|
||||||
|
// Offline returns the records whose IDs are not in online, newest last_seen
|
||||||
|
// first, converted to the hub's device payload shape with Online=false.
|
||||||
|
//
|
||||||
|
// Callers pass the hub's current online snapshot; the store deliberately does
|
||||||
|
// not know which device is online, so a reconnect that re-registers the same
|
||||||
|
// ID can never produce a duplicate row.
|
||||||
|
func (s *Store) Offline(online map[string]struct{}) []hub.DeviceInfo {
|
||||||
|
s.mu.Lock()
|
||||||
|
out := make([]hub.DeviceInfo, 0, len(s.devices))
|
||||||
|
for id, rec := range s.devices {
|
||||||
|
if _, ok := online[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, hub.DeviceInfo{
|
||||||
|
ID: rec.ID,
|
||||||
|
Name: rec.Name,
|
||||||
|
Group: rec.Group,
|
||||||
|
Version: rec.Version,
|
||||||
|
Capability: rec.Capability,
|
||||||
|
OS: rec.OS,
|
||||||
|
CPU: rec.CPU,
|
||||||
|
FilePath: rec.FilePath,
|
||||||
|
InstallTime: rec.InstallTime,
|
||||||
|
Location: rec.Location,
|
||||||
|
IP: rec.IP,
|
||||||
|
PeerIP: rec.PeerIP,
|
||||||
|
Screen: rec.Screen,
|
||||||
|
LastSeen: rec.LastSeen,
|
||||||
|
Online: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].LastSeen != out[j].LastSeen {
|
||||||
|
return out[i].LastSeen > out[j].LastSeen
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deletes the persisted record for id and reports whether one existed.
|
||||||
|
// This is the web UI's explicit "delete" for an offline device: an offline
|
||||||
|
// record has no live agent to uninstall, so forgetting it is all "delete" can
|
||||||
|
// mean. The caller decides when removal is appropriate; the store does not
|
||||||
|
// refuse to drop a record that happens to be marked online.
|
||||||
|
func (s *Store) Remove(id string) bool {
|
||||||
|
if id == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
_, existed := s.devices[id]
|
||||||
|
if existed {
|
||||||
|
delete(s.devices, id)
|
||||||
|
delete(s.online, id)
|
||||||
|
s.markDirtyLocked()
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return existed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush writes the current state synchronously. Normally the debounced timer
|
||||||
|
// does this; Flush exists for tests and graceful shutdown.
|
||||||
|
func (s *Store) Flush() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.timer != nil {
|
||||||
|
s.timer.Stop()
|
||||||
|
s.timer = nil
|
||||||
|
}
|
||||||
|
if !s.dirty {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.dirty = false
|
||||||
|
data, err := s.marshalLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeAtomic(s.path, data); err != nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.dirty = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the debounce timer and flushes pending changes.
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
return s.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) markDirtyLocked() {
|
||||||
|
s.dirty = true
|
||||||
|
if s.timer == nil {
|
||||||
|
s.timer = time.AfterFunc(flushDelay, s.flush)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) flush() {
|
||||||
|
if err := s.Flush(); err != nil && s.log != nil {
|
||||||
|
s.log.Error("Failed to persist device history to %s: %v", s.path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) marshalLocked() ([]byte, error) {
|
||||||
|
f := fileShape{Version: fileVersion, Devices: s.devices}
|
||||||
|
return json.MarshalIndent(f, "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneLocked keeps the max most-recently-seen records. It runs after every
|
||||||
|
// online upsert, which is the only path that can grow the map; the O(n log n)
|
||||||
|
// sort only happens when the cap is actually exceeded.
|
||||||
|
func (s *Store) pruneLocked() {
|
||||||
|
if s.max <= 0 || len(s.devices) <= s.max {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(s.devices))
|
||||||
|
for id := range s.devices {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
sort.Slice(ids, func(i, j int) bool {
|
||||||
|
oi, oj := s.online[ids[i]], s.online[ids[j]]
|
||||||
|
if oi != oj {
|
||||||
|
return !oi // evict offline records before online ones
|
||||||
|
}
|
||||||
|
return s.devices[ids[i]].LastSeen < s.devices[ids[j]].LastSeen
|
||||||
|
})
|
||||||
|
for _, id := range ids[:len(ids)-s.max] {
|
||||||
|
delete(s.devices, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAtomic mirrors wsauth.writeUsersFile: temp file in the same directory,
|
||||||
|
// fsync, then rename over the destination.
|
||||||
|
func writeAtomic(path string, data []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if dir != "" {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(dir, "devices-*.json.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package devicehistory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testDevice(id, name string) hub.DeviceInfo {
|
||||||
|
return hub.DeviceInfo{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Group: "office",
|
||||||
|
Version: "1.2.3",
|
||||||
|
OS: "Windows 11",
|
||||||
|
IP: "203.0.113.7",
|
||||||
|
PeerIP: "198.51.100.9",
|
||||||
|
Screen: "1:1920*1080",
|
||||||
|
Online: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreRecordsLifecycleViaHub(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
s, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
h := hub.New()
|
||||||
|
h.Subscribe(s)
|
||||||
|
|
||||||
|
h.Register(&hub.Device{ID: "dev-1", Name: "PC-1", Group: "office"},
|
||||||
|
&connection.Context{RemoteAddr: "198.51.100.9:5000"})
|
||||||
|
|
||||||
|
// The device is online, so it must not appear in the offline list.
|
||||||
|
if got := s.Offline(map[string]struct{}{"dev-1": {}}); len(got) != 0 {
|
||||||
|
t.Fatalf("offline while online = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Unregister("dev-1")
|
||||||
|
|
||||||
|
off := s.Offline(nil)
|
||||||
|
if len(off) != 1 {
|
||||||
|
t.Fatalf("offline entries = %d, want 1", len(off))
|
||||||
|
}
|
||||||
|
d := off[0]
|
||||||
|
if d.ID != "dev-1" || d.Name != "PC-1" || d.Group != "office" {
|
||||||
|
t.Fatalf("offline device = %+v", d)
|
||||||
|
}
|
||||||
|
if d.Online {
|
||||||
|
t.Fatal("offline entry marked online")
|
||||||
|
}
|
||||||
|
if d.LastSeen == 0 {
|
||||||
|
t.Fatal("last_seen not set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorePersistsAndPreservesFirstSeen(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
s, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := time.Unix(1_700_000_000, 0)
|
||||||
|
s.upsertAt(testDevice("dev-1", "old-name"), first)
|
||||||
|
// A later login refreshes display fields and last_seen but keeps first_seen.
|
||||||
|
s.upsertAt(testDevice("dev-1", "new-name"), first.Add(time.Hour))
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Fatalf("Flush: %v", err)
|
||||||
|
}
|
||||||
|
_ = s.Close()
|
||||||
|
|
||||||
|
reopened, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = reopened.Close() })
|
||||||
|
|
||||||
|
reopened.mu.Lock()
|
||||||
|
rec := reopened.devices["dev-1"]
|
||||||
|
reopened.mu.Unlock()
|
||||||
|
if rec == nil {
|
||||||
|
t.Fatal("record not persisted")
|
||||||
|
}
|
||||||
|
if rec.Name != "new-name" {
|
||||||
|
t.Fatalf("name = %q, want new-name", rec.Name)
|
||||||
|
}
|
||||||
|
if rec.FirstSeen != first.Unix() {
|
||||||
|
t.Fatalf("first_seen = %d, want %d", rec.FirstSeen, first.Unix())
|
||||||
|
}
|
||||||
|
if rec.LastSeen != first.Add(time.Hour).Unix() {
|
||||||
|
t.Fatalf("last_seen = %d, want %d", rec.LastSeen, first.Add(time.Hour).Unix())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorePrunesOldest(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
s, err := Open(path, 2, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
base := time.Unix(1_700_000_000, 0)
|
||||||
|
s.upsertAt(testDevice("a", "A"), base)
|
||||||
|
s.upsertAt(testDevice("b", "B"), base.Add(time.Minute))
|
||||||
|
s.upsertAt(testDevice("c", "C"), base.Add(2*time.Minute))
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
_, hasA := s.devices["a"]
|
||||||
|
_, hasC := s.devices["c"]
|
||||||
|
n := len(s.devices)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if n != 2 || hasA || !hasC {
|
||||||
|
t.Fatalf("after prune: n=%d hasA=%v hasC=%v, want 2/false/true", n, hasA, hasC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneEvictsOfflineBeforeOnline(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
s, err := Open(path, 2, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
base := time.Unix(1_700_000_000, 0)
|
||||||
|
s.upsertAt(testDevice("online-old", "Old"), base)
|
||||||
|
s.upsertAt(testDevice("offline-new", "Gone"), base.Add(time.Minute))
|
||||||
|
s.offlineAt("offline-new", base.Add(2*time.Minute))
|
||||||
|
// This upsert exceeds the cap; the offline record must be evicted before
|
||||||
|
// the still-connected device, even though its last_seen is newer.
|
||||||
|
s.upsertAt(testDevice("online-2", "New"), base.Add(3*time.Minute))
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
_, hasOld := s.devices["online-old"]
|
||||||
|
_, hasGone := s.devices["offline-new"]
|
||||||
|
_, hasNew := s.devices["online-2"]
|
||||||
|
n := len(s.devices)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if n != 2 || !hasOld || hasGone || !hasNew {
|
||||||
|
t.Fatalf("after prune: n=%d hasOld=%v hasGone=%v hasNew=%v", n, hasOld, hasGone, hasNew)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOfflineSortedNewestFirst(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
s, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
base := time.Unix(1_700_000_000, 0)
|
||||||
|
s.upsertAt(testDevice("old", "Old"), base)
|
||||||
|
s.upsertAt(testDevice("new", "New"), base.Add(time.Hour))
|
||||||
|
|
||||||
|
off := s.Offline(nil)
|
||||||
|
if len(off) != 2 || off[0].ID != "new" || off[1].ID != "old" {
|
||||||
|
t.Fatalf("offline order = %+v, want new then old", off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCorruptFileStartsEmptyAndRecovers(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := Open(path, 1000, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Open corrupt file: want error, got nil")
|
||||||
|
}
|
||||||
|
if len(s.devices) != 0 {
|
||||||
|
t.Fatalf("corrupt load produced %d records, want 0", len(s.devices))
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Fatalf("Flush after corrupt load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reopened, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = reopened.Close() })
|
||||||
|
if len(reopened.devices) != 1 {
|
||||||
|
t.Fatalf("records after recovery = %d, want 1", len(reopened.devices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreRemovePersistsDeletion(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
s, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := time.Unix(1_700_000_000, 0)
|
||||||
|
s.upsertAt(testDevice("gone", "Gone"), base)
|
||||||
|
s.upsertAt(testDevice("kept", "Kept"), base.Add(time.Minute))
|
||||||
|
|
||||||
|
if !s.Remove("gone") {
|
||||||
|
t.Fatal("Remove existing record = false, want true")
|
||||||
|
}
|
||||||
|
if s.Remove("gone") {
|
||||||
|
t.Fatal("Remove missing record = true, want false")
|
||||||
|
}
|
||||||
|
if got := s.Offline(nil); len(got) != 1 || got[0].ID != "kept" {
|
||||||
|
t.Fatalf("offline after remove = %+v, want only kept", got)
|
||||||
|
}
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Fatalf("Flush: %v", err)
|
||||||
|
}
|
||||||
|
_ = s.Close()
|
||||||
|
|
||||||
|
reopened, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = reopened.Close() })
|
||||||
|
if _, ok := reopened.devices["gone"]; ok {
|
||||||
|
t.Fatal("removed record came back after reload")
|
||||||
|
}
|
||||||
|
if _, ok := reopened.devices["kept"]; !ok {
|
||||||
|
t.Fatal("kept record missing after reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlushLeavesNoTempFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "devices.json")
|
||||||
|
s, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Fatalf("Flush: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasSuffix(e.Name(), ".tmp") {
|
||||||
|
t.Fatalf("leftover temp file: %s", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,6 +165,9 @@ type DeviceInfo struct {
|
|||||||
ActiveWindow string `json:"activeWindow,omitempty"`
|
ActiveWindow string `json:"activeWindow,omitempty"`
|
||||||
ConnectedAt int64 `json:"connected_at"`
|
ConnectedAt int64 `json:"connected_at"`
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
|
// LastSeen is unix seconds of the last login/disconnect. Only populated
|
||||||
|
// for offline entries in device_list; online devices leave it zero.
|
||||||
|
LastSeen int64 `json:"last_seen,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventHandler receives notifications about device lifecycle, per-tick live
|
// EventHandler receives notifications about device lifecycle, per-tick live
|
||||||
@@ -240,6 +243,17 @@ func (h *Hub) SetSender(fn SendFunc) {
|
|||||||
h.sender = fn
|
h.sender = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsOnline reports whether id is currently registered with a live main
|
||||||
|
// connection. Callers that need to choose between an online-only command and
|
||||||
|
// an offline bookkeeping action should check this first; SendToDevice can
|
||||||
|
// still turn into ErrDeviceOffline if the device drops in between.
|
||||||
|
func (h *Hub) IsOnline(id string) bool {
|
||||||
|
h.mu.RLock()
|
||||||
|
d, ok := h.devices[id]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
return ok && d.conn != nil
|
||||||
|
}
|
||||||
|
|
||||||
// SendToDevice forwards an already-formed command payload to the device's
|
// SendToDevice forwards an already-formed command payload to the device's
|
||||||
// main connection. data should be the raw command bytes (the sender takes
|
// main connection. data should be the raw command bytes (the sender takes
|
||||||
// care of framing / compression at the protocol layer).
|
// care of framing / compression at the protocol layer).
|
||||||
|
|||||||
+185
-16
@@ -1,10 +1,12 @@
|
|||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"gopkg.in/natefinch/lumberjack.v2"
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
@@ -24,6 +26,13 @@ const (
|
|||||||
// Logger wraps zerolog.Logger
|
// Logger wraps zerolog.Logger
|
||||||
type Logger struct {
|
type Logger struct {
|
||||||
zl zerolog.Logger
|
zl zerolog.Logger
|
||||||
|
// audit is a second, file-only logger used for security audit records.
|
||||||
|
// It writes JSON lines to AuditFile so audit data is not buried in
|
||||||
|
// debug/operational output and can be retained independently.
|
||||||
|
audit zerolog.Logger
|
||||||
|
auditEnabled bool
|
||||||
|
auditPath string // "" when the separate audit file is disabled
|
||||||
|
closers []io.Closer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config holds logger configuration
|
// Config holds logger configuration
|
||||||
@@ -42,6 +51,19 @@ type Config struct {
|
|||||||
MaxAge int
|
MaxAge int
|
||||||
// Compress enables gzip compression for rotated files
|
// Compress enables gzip compression for rotated files
|
||||||
Compress bool
|
Compress bool
|
||||||
|
|
||||||
|
// AuditFile is the dedicated security-audit log path ("" disables the
|
||||||
|
// separate audit file; audit records still go to the main log). Audit
|
||||||
|
// records are JSON lines with audit=true, event=<name>, and contextual
|
||||||
|
// fields such as actor/role/ip/device_id.
|
||||||
|
AuditFile string
|
||||||
|
// AuditMaxSize / AuditMaxBackups / AuditMaxAge tune audit rotation.
|
||||||
|
// Zero values pick 100 MB / 20 files / 90 days.
|
||||||
|
AuditMaxSize int
|
||||||
|
AuditMaxBackups int
|
||||||
|
AuditMaxAge int
|
||||||
|
// AuditCompress enables gzip compression for rotated audit files.
|
||||||
|
AuditCompress bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns default configuration
|
// DefaultConfig returns default configuration
|
||||||
@@ -60,6 +82,7 @@ func DefaultConfig() Config {
|
|||||||
// New creates a new logger with config
|
// New creates a new logger with config
|
||||||
func New(cfg Config) *Logger {
|
func New(cfg Config) *Logger {
|
||||||
var writers []io.Writer
|
var writers []io.Writer
|
||||||
|
var closers []io.Closer
|
||||||
|
|
||||||
// Console output with color
|
// Console output with color
|
||||||
if cfg.Console {
|
if cfg.Console {
|
||||||
@@ -87,6 +110,7 @@ func New(cfg Config) *Logger {
|
|||||||
LocalTime: true,
|
LocalTime: true,
|
||||||
}
|
}
|
||||||
writers = append(writers, fileWriter)
|
writers = append(writers, fileWriter)
|
||||||
|
closers = append(closers, fileWriter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine writers
|
// Combine writers
|
||||||
@@ -106,14 +130,78 @@ func New(cfg Config) *Logger {
|
|||||||
Timestamp().
|
Timestamp().
|
||||||
Logger()
|
Logger()
|
||||||
|
|
||||||
return &Logger{zl: zl}
|
l := &Logger{zl: zl, closers: closers}
|
||||||
|
|
||||||
|
if cfg.AuditFile != "" {
|
||||||
|
dir := filepath.Dir(cfg.AuditFile)
|
||||||
|
if dir != "" && dir != "." {
|
||||||
|
_ = os.MkdirAll(dir, 0755)
|
||||||
|
}
|
||||||
|
maxSize := cfg.AuditMaxSize
|
||||||
|
if maxSize <= 0 {
|
||||||
|
maxSize = 100
|
||||||
|
}
|
||||||
|
maxBackups := cfg.AuditMaxBackups
|
||||||
|
if maxBackups <= 0 {
|
||||||
|
maxBackups = 20
|
||||||
|
}
|
||||||
|
maxAge := cfg.AuditMaxAge
|
||||||
|
if maxAge <= 0 {
|
||||||
|
maxAge = 90
|
||||||
|
}
|
||||||
|
auditWriter := &lumberjack.Logger{
|
||||||
|
Filename: cfg.AuditFile,
|
||||||
|
MaxSize: maxSize,
|
||||||
|
MaxBackups: maxBackups,
|
||||||
|
MaxAge: maxAge,
|
||||||
|
Compress: cfg.AuditCompress,
|
||||||
|
LocalTime: true,
|
||||||
|
}
|
||||||
|
l.audit = zerolog.New(auditWriter).
|
||||||
|
Level(zerolog.InfoLevel).
|
||||||
|
With().
|
||||||
|
Timestamp().
|
||||||
|
Logger()
|
||||||
|
l.auditEnabled = true
|
||||||
|
l.auditPath = cfg.AuditFile
|
||||||
|
l.closers = append(l.closers, auditWriter)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases any file handles owned by the logger. Safe to call multiple
|
||||||
|
// times. Loggers derived with WithPrefix share the base logger's files but do
|
||||||
|
// not own them, so call Close on the logger returned by New.
|
||||||
|
func (l *Logger) Close() error {
|
||||||
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, c := range l.closers {
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
|
l.closers = nil
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithPrefix returns a new logger with a prefix field
|
// WithPrefix returns a new logger with a prefix field
|
||||||
func (l *Logger) WithPrefix(prefix string) *Logger {
|
func (l *Logger) WithPrefix(prefix string) *Logger {
|
||||||
return &Logger{
|
n := &Logger{zl: l.zl.With().Str("module", prefix).Logger(), auditPath: l.auditPath}
|
||||||
zl: l.zl.With().Str("module", prefix).Logger(),
|
if l.auditEnabled {
|
||||||
|
n.audit = l.audit.With().Str("module", prefix).Logger()
|
||||||
|
n.auditEnabled = true
|
||||||
}
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditPath returns the path of the dedicated audit log file, or "" when the
|
||||||
|
// separate audit file is disabled (audit records then only reach the main log).
|
||||||
|
// Derived loggers (WithPrefix) report the same path.
|
||||||
|
func (l *Logger) AuditPath() string {
|
||||||
|
if l == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l.auditPath
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logs a debug message
|
// Debug logs a debug message
|
||||||
@@ -151,21 +239,102 @@ func (l *Logger) GetLevel() Level {
|
|||||||
return l.zl.GetLevel()
|
return l.zl.GetLevel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientEvent logs client online/offline events
|
// ClientEvent logs client online/offline events as audit records.
|
||||||
func (l *Logger) ClientEvent(event string, clientID uint64, ip string, extra ...string) {
|
func (l *Logger) ClientEvent(event string, clientID uint64, ip string, extra ...string) {
|
||||||
e := l.zl.Info().
|
// clientID is the TCP connection id; the device's stable YAMA id arrives
|
||||||
Str("event", event).
|
// as the "clientID" extra and is normalized to device_id so every audit
|
||||||
Uint64("client_id", clientID).
|
// record uses the same field names (conn / device_id).
|
||||||
Str("ip", ip).
|
fields := map[string]any{
|
||||||
Time("time", time.Now())
|
"conn": clientID,
|
||||||
|
"ip": ip,
|
||||||
if len(extra) >= 2 {
|
|
||||||
for i := 0; i+1 < len(extra); i += 2 {
|
|
||||||
e = e.Str(extra[i], extra[i+1])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
for i := 0; i+1 < len(extra); i += 2 {
|
||||||
|
key := extra[i]
|
||||||
|
if key == "clientID" {
|
||||||
|
key = "device_id"
|
||||||
|
}
|
||||||
|
fields[key] = extra[i+1]
|
||||||
|
}
|
||||||
|
l.Audit("device."+event, fields)
|
||||||
|
}
|
||||||
|
|
||||||
e.Msg("")
|
// Audit writes one security-audit record at info level.
|
||||||
|
func (l *Logger) Audit(event string, fields map[string]any) {
|
||||||
|
l.AuditAt(LevelInfo, event, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditWarn writes one security-audit record at warn level. Use it for
|
||||||
|
// rejected / failed security-relevant operations.
|
||||||
|
func (l *Logger) AuditWarn(event string, fields map[string]any) {
|
||||||
|
l.AuditAt(LevelWarn, event, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditAt writes a security-audit record to the dedicated audit file (when
|
||||||
|
// configured) and mirrors it to the main logger so operators tailing
|
||||||
|
// server.log still see security events. event is a stable dotted name such
|
||||||
|
// as "web.login.failure"; fields carry actor/role/ip/device_id/reason.
|
||||||
|
//
|
||||||
|
// Untrusted string values (usernames, User-Agent, Origin, device strings)
|
||||||
|
// are sanitized: control characters are replaced so they cannot forge log
|
||||||
|
// lines, and long values are truncated.
|
||||||
|
func (l *Logger) AuditAt(level Level, event string, fields map[string]any) {
|
||||||
|
if l == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clean := make(map[string]any, len(fields)+2)
|
||||||
|
for k, v := range fields {
|
||||||
|
clean[k] = sanitizeAuditValue(v)
|
||||||
|
}
|
||||||
|
clean["audit"] = true
|
||||||
|
clean["event"] = event
|
||||||
|
|
||||||
|
if l.auditEnabled {
|
||||||
|
l.audit.WithLevel(level).Fields(clean).Msg("audit")
|
||||||
|
}
|
||||||
|
l.zl.WithLevel(level).Fields(clean).Msg("audit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxAuditValueLen bounds untrusted log values (runes). A 1 MB WebSocket
|
||||||
|
// frame could otherwise be reflected verbatim into every audit line.
|
||||||
|
const maxAuditValueLen = 512
|
||||||
|
|
||||||
|
// maxAuditValueBytes is the pre-sanitization byte cap. It keeps a single
|
||||||
|
// hostile value from allocating an unbounded rune slice before truncation.
|
||||||
|
const maxAuditValueBytes = 4096
|
||||||
|
|
||||||
|
func sanitizeAuditValue(v any) any {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
return sanitizeAuditString(x)
|
||||||
|
case []byte:
|
||||||
|
return sanitizeAuditString(string(x))
|
||||||
|
case error:
|
||||||
|
return sanitizeAuditString(x.Error())
|
||||||
|
case fmt.Stringer:
|
||||||
|
return sanitizeAuditString(x.String())
|
||||||
|
default:
|
||||||
|
// Numeric/bool values are code-controlled and log-safe.
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeAuditString(s string) string {
|
||||||
|
if len(s) > maxAuditValueBytes {
|
||||||
|
s = s[:maxAuditValueBytes]
|
||||||
|
}
|
||||||
|
s = strings.Map(func(r rune) rune {
|
||||||
|
// C0 controls, DEL, and Unicode line/paragraph separators can be
|
||||||
|
// used to forge or visually split log lines in console output.
|
||||||
|
if r < 0x20 || r == 0x7f || r == 0x2028 || r == 0x2029 {
|
||||||
|
return ' '
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, s)
|
||||||
|
if utf8.RuneCountInString(s) > maxAuditValueLen {
|
||||||
|
r := []rune(s)
|
||||||
|
s = string(r[:maxAuditValueLen])
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// default global logger
|
// default global logger
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readAuditLines parses logs/audit.log-style JSON lines. Callers assert on
|
||||||
|
// individual records rather than raw text so field ordering is irrelevant.
|
||||||
|
func readAuditLines(t *testing.T, path string) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open audit log: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var out []map[string]any
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||||
|
t.Fatalf("audit line is not JSON: %q: %v", line, err)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
t.Fatalf("scan audit log: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditWritesDedicatedFileAndMirrorsMainLog(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
auditPath := filepath.Join(dir, "audit.log")
|
||||||
|
mainPath := filepath.Join(dir, "server.log")
|
||||||
|
|
||||||
|
l := New(Config{
|
||||||
|
Level: LevelInfo,
|
||||||
|
Console: false,
|
||||||
|
File: mainPath,
|
||||||
|
AuditFile: auditPath,
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { _ = l.Close() })
|
||||||
|
|
||||||
|
l.Audit("web.login.success", map[string]any{
|
||||||
|
"actor": "admin",
|
||||||
|
"role": "admin",
|
||||||
|
"ip": "203.0.113.9",
|
||||||
|
})
|
||||||
|
|
||||||
|
lines := readAuditLines(t, auditPath)
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("audit lines = %d, want 1", len(lines))
|
||||||
|
}
|
||||||
|
rec := lines[0]
|
||||||
|
if rec["audit"] != true {
|
||||||
|
t.Errorf("audit = %v, want true", rec["audit"])
|
||||||
|
}
|
||||||
|
if rec["event"] != "web.login.success" {
|
||||||
|
t.Errorf("event = %v", rec["event"])
|
||||||
|
}
|
||||||
|
if rec["actor"] != "admin" || rec["role"] != "admin" || rec["ip"] != "203.0.113.9" {
|
||||||
|
t.Errorf("fields = %+v", rec)
|
||||||
|
}
|
||||||
|
if _, ok := rec["time"]; !ok {
|
||||||
|
t.Error("record has no timestamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same record must reach server.log so operators tailing one file
|
||||||
|
// still see security events.
|
||||||
|
mainLines := readAuditLines(t, mainPath)
|
||||||
|
found := false
|
||||||
|
for _, m := range mainLines {
|
||||||
|
if m["event"] == "web.login.success" && m["audit"] == true {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("audit record not mirrored to main log: %+v", mainLines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditSanitizesUntrustedValues(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
auditPath := filepath.Join(dir, "audit.log")
|
||||||
|
l := New(Config{Console: false, File: filepath.Join(dir, "server.log"), AuditFile: auditPath})
|
||||||
|
t.Cleanup(func() { _ = l.Close() })
|
||||||
|
|
||||||
|
l.Audit("web.login.failure", map[string]any{
|
||||||
|
// Newlines must not be able to forge extra log lines.
|
||||||
|
"username": "evil\n{\"audit\":true,\"event\":\"web.login.success\"}",
|
||||||
|
"ua": strings.Repeat("A", maxAuditValueLen+50),
|
||||||
|
})
|
||||||
|
|
||||||
|
lines := readAuditLines(t, auditPath)
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("audit lines = %d, want 1 (newline forged a record?)", len(lines))
|
||||||
|
}
|
||||||
|
username, _ := lines[0]["username"].(string)
|
||||||
|
if strings.ContainsAny(username, "\n\r") {
|
||||||
|
t.Errorf("username still contains a newline: %q", username)
|
||||||
|
}
|
||||||
|
if !strings.Contains(username, `{"audit":true`) {
|
||||||
|
t.Errorf("username content lost: %q", username)
|
||||||
|
}
|
||||||
|
ua, _ := lines[0]["ua"].(string)
|
||||||
|
if got := len([]rune(ua)); got != maxAuditValueLen {
|
||||||
|
t.Errorf("ua rune length = %d, want %d", got, maxAuditValueLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditDisabledFileStillLogsToMain(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mainPath := filepath.Join(dir, "server.log")
|
||||||
|
l := New(Config{Console: false, File: mainPath}) // no AuditFile
|
||||||
|
t.Cleanup(func() { _ = l.Close() })
|
||||||
|
|
||||||
|
l.AuditWarn("web.authz.denied", map[string]any{"role": "viewer"})
|
||||||
|
|
||||||
|
lines := readAuditLines(t, mainPath)
|
||||||
|
if len(lines) != 1 || lines[0]["event"] != "web.authz.denied" {
|
||||||
|
t.Fatalf("main log lines = %+v", lines)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "audit.log")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("audit.log unexpectedly created: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientEventEmitsDeviceAudit(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
auditPath := filepath.Join(dir, "audit.log")
|
||||||
|
l := New(Config{Console: false, File: filepath.Join(dir, "server.log"), AuditFile: auditPath})
|
||||||
|
t.Cleanup(func() { _ = l.Close() })
|
||||||
|
|
||||||
|
l.ClientEvent("online", 42, "198.51.100.7", "clientID", "abc", "computer", "PC-01")
|
||||||
|
|
||||||
|
lines := readAuditLines(t, auditPath)
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("audit lines = %d, want 1", len(lines))
|
||||||
|
}
|
||||||
|
if lines[0]["event"] != "device.online" {
|
||||||
|
t.Errorf("event = %v, want device.online", lines[0]["event"])
|
||||||
|
}
|
||||||
|
if lines[0]["device_id"] != "abc" {
|
||||||
|
t.Errorf("device_id = %v", lines[0]["device_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,7 @@ const (
|
|||||||
CommandScreenSetClipboard byte = 25 // COMMAND_SCREEN_SET_CLIPBOARD - set remote clipboard [25][GBK text]
|
CommandScreenSetClipboard byte = 25 // COMMAND_SCREEN_SET_CLIPBOARD - set remote clipboard [25][GBK text]
|
||||||
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
|
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
|
||||||
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
|
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
|
||||||
|
CommandUninstall byte = 63 // TOKEN_UNINSTALL - uninstall the host agent (single byte)
|
||||||
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
|
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
|
||||||
CmdRestoreConsole byte = 82 // CMD_RESTORE_CONSOLE - RDP session "归位": switch back to the console session and restart capture
|
CmdRestoreConsole byte = 82 // CMD_RESTORE_CONSOLE - RDP session "归位": switch back to the console session and restart capture
|
||||||
CommandScreenKeyframeReq byte = 169 // COMMAND_SCREEN_KEYFRAME_REQ - force the client to emit an IDR immediately
|
CommandScreenKeyframeReq byte = 169 // COMMAND_SCREEN_KEYFRAME_REQ - force the client to emit an IDR immediately
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stable audit event names. Keep these stable: operators build alerts and
|
||||||
|
// dashboards on them. Every record also carries audit=true.
|
||||||
|
const (
|
||||||
|
auditWSConnect = "web.ws.connect"
|
||||||
|
auditWSReject = "web.ws.reject"
|
||||||
|
auditWSClose = "web.ws.close"
|
||||||
|
auditLoginOK = "web.login.success"
|
||||||
|
auditLoginFail = "web.login.failure"
|
||||||
|
auditLoginThrot = "web.login.throttled"
|
||||||
|
auditSession = "web.session.resume"
|
||||||
|
auditBadToken = "web.auth.invalid_token"
|
||||||
|
auditDenied = "web.authz.denied"
|
||||||
|
auditUnknownCmd = "web.command.unknown"
|
||||||
|
auditSaltProbe = "web.salt.unknown_user"
|
||||||
|
|
||||||
|
auditScreenStart = "remote.screen.start"
|
||||||
|
auditScreenStop = "remote.screen.stop"
|
||||||
|
auditScreenDenied = "remote.screen.denied"
|
||||||
|
auditTermStart = "remote.terminal.start"
|
||||||
|
auditTermStop = "remote.terminal.stop"
|
||||||
|
auditTermDenied = "remote.terminal.denied"
|
||||||
|
auditClipSet = "remote.clipboard.set"
|
||||||
|
auditClipGet = "remote.clipboard.get"
|
||||||
|
auditRdpReset = "remote.rdp_reset"
|
||||||
|
|
||||||
|
auditUserCreate = "admin.user.create"
|
||||||
|
auditUserDelete = "admin.user.delete"
|
||||||
|
auditDeviceAdmin = "admin.device.delete"
|
||||||
|
auditQuery = "admin.audit.query"
|
||||||
|
)
|
||||||
|
|
||||||
|
// audit emits one audit record for a WebSocket client. Identity fields are
|
||||||
|
// read under h.mu because requireAuth/handleLogin install them there and
|
||||||
|
// broadcast goroutines may observe the same client concurrently.
|
||||||
|
//
|
||||||
|
// Callers MUST NOT hold h.mu when calling this method.
|
||||||
|
func (h *wsHub) audit(c *wsClient, warn bool, event string, fields map[string]any) {
|
||||||
|
if fields == nil {
|
||||||
|
fields = make(map[string]any)
|
||||||
|
}
|
||||||
|
if c != nil {
|
||||||
|
h.mu.RLock()
|
||||||
|
connID := c.id
|
||||||
|
addr := c.addr
|
||||||
|
peer := c.peerAddr
|
||||||
|
user := c.user
|
||||||
|
role := c.role
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
if _, ok := fields["conn"]; !ok && connID != "" {
|
||||||
|
fields["conn"] = connID
|
||||||
|
}
|
||||||
|
if _, ok := fields["ip"]; !ok && addr != "" {
|
||||||
|
fields["ip"] = addr
|
||||||
|
}
|
||||||
|
if _, ok := fields["peer_ip"]; !ok && peer != "" && peer != addr {
|
||||||
|
fields["peer_ip"] = peer
|
||||||
|
}
|
||||||
|
if _, ok := fields["actor"]; !ok && user != "" {
|
||||||
|
fields["actor"] = user
|
||||||
|
}
|
||||||
|
if _, ok := fields["role"]; !ok && role != "" {
|
||||||
|
fields["role"] = role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if warn {
|
||||||
|
h.log.AuditWarn(event, fields)
|
||||||
|
} else {
|
||||||
|
h.log.Audit(event, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditClose records the end of a browser WebSocket. Anonymous scanners that
|
||||||
|
// connect and do nothing produce only the connect event; authenticated
|
||||||
|
// sessions, failed-login probes, and command traffic always produce a close
|
||||||
|
// record with counters that help spot brute-force / probing behavior.
|
||||||
|
func (h *wsHub) auditClose(c *wsClient, screens int) {
|
||||||
|
suspicious := c.statInvalidToken > 0 ||
|
||||||
|
c.statLoginFailures > 0 ||
|
||||||
|
c.statAdminDenied > 0 ||
|
||||||
|
c.statUnknownSalt > 0 ||
|
||||||
|
c.statUnknownCmd > 0
|
||||||
|
if c.user == "" && c.statCommands == 0 && !suspicious {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := map[string]any{
|
||||||
|
"duration_ms": time.Since(c.connectedAt).Milliseconds(),
|
||||||
|
"commands": c.statCommands,
|
||||||
|
}
|
||||||
|
if screens > 0 {
|
||||||
|
fields["screens"] = screens
|
||||||
|
}
|
||||||
|
if c.statInvalidToken > 0 {
|
||||||
|
fields["invalid_tokens"] = c.statInvalidToken
|
||||||
|
}
|
||||||
|
if c.statLoginFailures > 0 {
|
||||||
|
fields["login_failures"] = c.statLoginFailures
|
||||||
|
}
|
||||||
|
if c.statAdminDenied > 0 {
|
||||||
|
fields["admin_denied"] = c.statAdminDenied
|
||||||
|
}
|
||||||
|
if c.statUnknownSalt > 0 {
|
||||||
|
fields["unknown_salt"] = c.statUnknownSalt
|
||||||
|
}
|
||||||
|
if c.statUnknownCmd > 0 {
|
||||||
|
fields["unknown_commands"] = c.statUnknownCmd
|
||||||
|
}
|
||||||
|
h.audit(c, suspicious, auditWSClose, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestFields builds the audit context available before a WebSocket client
|
||||||
|
// exists (upgrade rejected) or for plain HTTP endpoints (SSO).
|
||||||
|
func requestFields(r *http.Request, trustForwardedFor bool) map[string]any {
|
||||||
|
fields := make(map[string]any)
|
||||||
|
if r == nil {
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
ip := clientIP(r, trustForwardedFor)
|
||||||
|
if ip != "" {
|
||||||
|
fields["ip"] = ip
|
||||||
|
}
|
||||||
|
if peer := stripPort(r.RemoteAddr); peer != "" && peer != ip {
|
||||||
|
fields["peer_ip"] = peer
|
||||||
|
}
|
||||||
|
if origin := r.Header.Get("Origin"); origin != "" {
|
||||||
|
fields["origin"] = origin
|
||||||
|
}
|
||||||
|
if ua := r.UserAgent(); ua != "" {
|
||||||
|
fields["ua"] = ua
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsRequestFields returns request metadata worth attaching to login events.
|
||||||
|
func (c *wsClient) wsRequestFields() map[string]any {
|
||||||
|
fields := make(map[string]any)
|
||||||
|
if c.origin != "" {
|
||||||
|
fields["origin"] = c.origin
|
||||||
|
}
|
||||||
|
if c.userAgent != "" {
|
||||||
|
fields["ua"] = c.userAgent
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func durationMs(start time.Time) int64 {
|
||||||
|
if start.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Since(start).Milliseconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newConnID returns a short random identifier used to correlate all audit
|
||||||
|
// records belonging to one browser WebSocket connection.
|
||||||
|
func newConnID() string {
|
||||||
|
var b [4]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "conn"
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Audit-log query support for the admin-only "Audit Log" page.
|
||||||
|
//
|
||||||
|
// The server reads its own JSON-lines audit file (logs/audit.log) back to the
|
||||||
|
// browser. Records are already redacted at write time (see logger.AuditAt), so
|
||||||
|
// returning raw lines is safe as long as we never widen what is written.
|
||||||
|
//
|
||||||
|
// Only the CURRENT audit file is searched. Rotated files (audit-*.log[.gz])
|
||||||
|
// are deliberately out of scope: they need gzip support and a separate index,
|
||||||
|
// and the UI is meant for recent-window triage ("what happened just now").
|
||||||
|
const (
|
||||||
|
auditQueryDefaultLimit = 100
|
||||||
|
auditQueryMaxLimit = 500
|
||||||
|
auditQueryMaxOffset = 10000
|
||||||
|
|
||||||
|
// auditQueryScanBudget bounds how much of the audit file a single query
|
||||||
|
// reads. The tail is read when the file is larger, so the newest records
|
||||||
|
// (what operators actually triage) are always covered and a multi-GB log
|
||||||
|
// cannot stall the WS goroutine.
|
||||||
|
auditQueryScanBudget = 32 << 20 // 32 MB
|
||||||
|
)
|
||||||
|
|
||||||
|
// auditQueryRequest is the JSON body of the `query_audit` command.
|
||||||
|
type auditQueryRequest struct {
|
||||||
|
From string `json:"from"` // RFC3339 ("2026-10-18T10:00:00Z"), optional
|
||||||
|
To string `json:"to"` // RFC3339, optional
|
||||||
|
Event string `json:"event"` // exact ("web.login.failure"), prefix ("web." / "web.*"), or ""
|
||||||
|
Actor string `json:"actor"` // exact, case-insensitive
|
||||||
|
IP string `json:"ip"` // exact
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Keyword string `json:"keyword"` // case-insensitive substring of the raw record
|
||||||
|
WarnOnly bool `json:"warn_only"` // only level=warn records
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"` // skip the newest N matching records
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditQueryResult is the internal result of scanning the audit file.
|
||||||
|
type auditQueryResult struct {
|
||||||
|
Events []map[string]any
|
||||||
|
Total int // matching records in the scanned window
|
||||||
|
FileExists bool
|
||||||
|
FileSize int64
|
||||||
|
ScannedBytes int64
|
||||||
|
Truncated bool // the file was larger than auditQueryScanBudget
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleQueryAudit implements the admin-only audit query command. The reply is
|
||||||
|
// `{cmd:"audit_result", ok, events, total, ...}`; events are newest-first.
|
||||||
|
func (h *wsHub) handleQueryAudit(c *wsClient, raw []byte) {
|
||||||
|
const replyCmd = "audit_result"
|
||||||
|
if !h.requireAdmin(c, raw, replyCmd) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in auditQueryRequest
|
||||||
|
if err := json.Unmarshal(raw, &in); err != nil {
|
||||||
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid request"}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Limit = clampAuditLimit(in.Limit)
|
||||||
|
in.Offset = clampAuditOffset(in.Offset)
|
||||||
|
|
||||||
|
path := h.log.AuditPath()
|
||||||
|
if path == "" {
|
||||||
|
h.audit(c, true, auditQuery, map[string]any{
|
||||||
|
"ok": false,
|
||||||
|
"reason": "audit_log_disabled",
|
||||||
|
})
|
||||||
|
c.queue(mustJSON(map[string]any{
|
||||||
|
"cmd": replyCmd, "ok": false,
|
||||||
|
"msg": "Audit log file is disabled on this server (YAMA_AUDIT_LOG=off)",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := queryAuditLog(path, in)
|
||||||
|
if err != nil {
|
||||||
|
h.audit(c, true, auditQuery, map[string]any{
|
||||||
|
"ok": false,
|
||||||
|
"reason": "read_failed",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
c.queue(mustJSON(map[string]any{
|
||||||
|
"cmd": replyCmd, "ok": false,
|
||||||
|
"msg": "Failed to read audit log: " + err.Error(),
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the query itself: an audit viewer is privileged, and reading the
|
||||||
|
// trail is itself an auditable action. Filter terms are included so an
|
||||||
|
// investigator can see what was searched for.
|
||||||
|
h.audit(c, false, auditQuery, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"event_filter": in.Event,
|
||||||
|
"actor_filter": in.Actor,
|
||||||
|
"ip_filter": in.IP,
|
||||||
|
"device_filter": in.DeviceID,
|
||||||
|
"keyword": in.Keyword,
|
||||||
|
"warn_only": in.WarnOnly,
|
||||||
|
"matches": res.Total,
|
||||||
|
"returned": len(res.Events),
|
||||||
|
"offset": in.Offset,
|
||||||
|
})
|
||||||
|
|
||||||
|
c.queue(mustJSON(map[string]any{
|
||||||
|
"cmd": replyCmd,
|
||||||
|
"ok": true,
|
||||||
|
"events": res.Events,
|
||||||
|
"total": res.Total,
|
||||||
|
"offset": in.Offset,
|
||||||
|
"limit": in.Limit,
|
||||||
|
"file": path,
|
||||||
|
"file_exists": res.FileExists,
|
||||||
|
"file_size": res.FileSize,
|
||||||
|
"scanned_bytes": res.ScannedBytes,
|
||||||
|
"truncated": res.Truncated,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampAuditLimit(n int) int {
|
||||||
|
if n <= 0 {
|
||||||
|
return auditQueryDefaultLimit
|
||||||
|
}
|
||||||
|
if n > auditQueryMaxLimit {
|
||||||
|
return auditQueryMaxLimit
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampAuditOffset(n int) int {
|
||||||
|
if n < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if n > auditQueryMaxOffset {
|
||||||
|
return auditQueryMaxOffset
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryAuditLog scans path for audit records matching q. The returned events
|
||||||
|
// are the newest limit records after skipping offset, in newest-first order.
|
||||||
|
func queryAuditLog(path string, q auditQueryRequest) (auditQueryResult, error) {
|
||||||
|
res := auditQueryResult{Events: []map[string]any{}}
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// No audit record has been written yet (lumberjack creates the file
|
||||||
|
// lazily). An empty result is correct, not an error.
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
res.FileExists = true
|
||||||
|
res.FileSize = info.Size()
|
||||||
|
|
||||||
|
// For very large files, scan only the tail; the newest matches live there.
|
||||||
|
start := int64(0)
|
||||||
|
if info.Size() > auditQueryScanBudget {
|
||||||
|
start = info.Size() - auditQueryScanBudget
|
||||||
|
if _, err := f.Seek(start, 0); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
res.Truncated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
from, hasFrom := parseAuditTime(q.From)
|
||||||
|
to, hasTo := parseAuditTime(q.To)
|
||||||
|
|
||||||
|
keep := q.Offset + q.Limit
|
||||||
|
ring := make([]map[string]any, keep)
|
||||||
|
filled := 0
|
||||||
|
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
// Sanitized values are capped at 4096 bytes each; a record with many
|
||||||
|
// fields stays well under 1 MB. Allow headroom rather than aborting the
|
||||||
|
// whole query on one long line.
|
||||||
|
sc.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||||
|
|
||||||
|
first := true
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
if start > 0 && first {
|
||||||
|
// Seeking into the middle of the file lands mid-line. Drop that
|
||||||
|
// partial record.
|
||||||
|
first = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
res.ScannedBytes += int64(len(line)) + 1
|
||||||
|
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||||
|
continue // tolerate a torn line instead of failing the page
|
||||||
|
}
|
||||||
|
if _, ok := rec["event"].(string); !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Only audit records belong on the page. The dedicated file contains
|
||||||
|
// nothing else, but a hand-edited or mixed file must not surface
|
||||||
|
// ordinary operational lines here.
|
||||||
|
if rec["audit"] != true {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !auditRecordMatches(rec, line, q, from, hasFrom, to, hasTo) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Ring buffer of the last `keep` matches (file order is chronological).
|
||||||
|
ring[res.Total%keep] = rec
|
||||||
|
if filled < keep {
|
||||||
|
filled++
|
||||||
|
}
|
||||||
|
res.Total++
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest first, skipping Offset. The match with 0-based index i lives at
|
||||||
|
// ring[i%keep] as long as i >= Total-filled.
|
||||||
|
for i := res.Total - 1 - q.Offset; i >= 0 && i >= res.Total-filled; i-- {
|
||||||
|
res.Events = append(res.Events, ring[i%keep])
|
||||||
|
if len(res.Events) >= q.Limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditRecordMatches applies the non-time and time filters to one record.
|
||||||
|
// line is the raw JSON line, used for the keyword substring search.
|
||||||
|
func auditRecordMatches(rec map[string]any, line string, q auditQueryRequest, from time.Time, hasFrom bool, to time.Time, hasTo bool) bool {
|
||||||
|
if q.Event != "" && !auditEventFilterMatches(q.Event, auditStringField(rec, "event")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if q.Actor != "" && !strings.EqualFold(auditStringField(rec, "actor"), q.Actor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if q.IP != "" && auditStringField(rec, "ip") != q.IP {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if q.DeviceID != "" && auditStringField(rec, "device_id") != q.DeviceID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if q.WarnOnly && auditStringField(rec, "level") != "warn" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if q.Keyword != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(q.Keyword)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if hasFrom || hasTo {
|
||||||
|
t, ok := parseAuditTime(auditStringField(rec, "time"))
|
||||||
|
if !ok {
|
||||||
|
return false // cannot prove it is in range
|
||||||
|
}
|
||||||
|
if hasFrom && t.Before(from) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if hasTo && t.After(to) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditEventFilterMatches supports three forms so the UI stays simple:
|
||||||
|
//
|
||||||
|
// "web.login.failure" exact
|
||||||
|
// "web.*" prefix (trailing "*")
|
||||||
|
// "web." prefix (trailing ".")
|
||||||
|
func auditEventFilterMatches(filter, event string) bool {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(filter, "*"):
|
||||||
|
return strings.HasPrefix(event, strings.TrimSuffix(filter, "*"))
|
||||||
|
case strings.HasSuffix(filter, "."):
|
||||||
|
return strings.HasPrefix(event, filter)
|
||||||
|
default:
|
||||||
|
return event == filter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditStringField(rec map[string]any, key string) string {
|
||||||
|
s, _ := rec[key].(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAuditTime accepts the formats operators and browsers realistically send.
|
||||||
|
// Layouts without an explicit zone are parsed as UTC; the frontend always sends
|
||||||
|
// Date.toISOString(), so the RFC3339 path is what production uses.
|
||||||
|
func parseAuditTime(s string) (time.Time, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
layouts := []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
"2006-01-02T15:04",
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02",
|
||||||
|
}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
if t, err := time.Parse(layout, s); err == nil {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newAuditTestHubWithoutAuditFile builds a hub whose logger has no dedicated
|
||||||
|
// audit file, mirroring a server started with YAMA_AUDIT_LOG=off.
|
||||||
|
func newAuditTestHubWithoutAuditFile(t *testing.T, dir string) (*wsHub, string) {
|
||||||
|
t.Helper()
|
||||||
|
lg := logger.New(logger.Config{
|
||||||
|
Level: logger.LevelInfo,
|
||||||
|
Console: false,
|
||||||
|
File: filepath.Join(dir, "server.log"),
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { _ = lg.Close() })
|
||||||
|
wh := newWSHub(wsauth.New(), hub.New(), nil, lg)
|
||||||
|
t.Cleanup(wh.stop)
|
||||||
|
return wh, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func readQueuedJSON(t *testing.T, c *wsClient) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case m := <-c.send:
|
||||||
|
var v map[string]any
|
||||||
|
if err := json.Unmarshal(m.data, &v); err != nil {
|
||||||
|
t.Fatalf("queued reply is not JSON: %q: %v", m.data, err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for a queued reply")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAuditFixture writes JSON-lines audit records (oldest first) and returns
|
||||||
|
// the file path. Times are UTC RFC3339 so the tests are timezone-independent.
|
||||||
|
func writeAuditFixture(t *testing.T, records []map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "audit.log")
|
||||||
|
var b strings.Builder
|
||||||
|
for _, rec := range records {
|
||||||
|
line, err := json.Marshal(rec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
b.Write(line)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditFixtureRecords() []map[string]any {
|
||||||
|
at := func(hhmm string) string {
|
||||||
|
tm, err := time.Parse("2006-01-02T15:04", "2026-10-18T"+hhmm)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return tm.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return []map[string]any{
|
||||||
|
{"level": "info", "time": at("10:00"), "audit": true, "event": "web.login.success", "actor": "admin", "ip": "203.0.113.1"},
|
||||||
|
{"level": "warn", "time": at("10:01"), "audit": true, "event": "web.login.failure", "actor": "alice", "ip": "203.0.113.2", "reason": "invalid_credentials"},
|
||||||
|
{"level": "info", "time": at("10:02"), "audit": true, "event": "remote.screen.start", "actor": "admin", "role": "admin", "ip": "203.0.113.1", "device_id": "dev1"},
|
||||||
|
{"level": "info", "time": at("10:03"), "audit": true, "event": "admin.user.delete", "actor": "admin", "ip": "203.0.113.1", "target": "bob"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAuditQuery(t *testing.T, path string, q auditQueryRequest) auditQueryResult {
|
||||||
|
t.Helper()
|
||||||
|
q.Limit = clampAuditLimit(q.Limit)
|
||||||
|
q.Offset = clampAuditOffset(q.Offset)
|
||||||
|
res, err := queryAuditLog(path, q)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("queryAuditLog: %v", err)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventNames(res auditQueryResult) []string {
|
||||||
|
out := make([]string, 0, len(res.Events))
|
||||||
|
for _, e := range res.Events {
|
||||||
|
out = append(out, auditStringField(e, "event"))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditQueryNoFilterNewestFirst(t *testing.T) {
|
||||||
|
path := writeAuditFixture(t, auditFixtureRecords())
|
||||||
|
res := runAuditQuery(t, path, auditQueryRequest{Limit: 100})
|
||||||
|
if res.Total != 4 || len(res.Events) != 4 {
|
||||||
|
t.Fatalf("total=%d events=%d, want 4/4", res.Total, len(res.Events))
|
||||||
|
}
|
||||||
|
got := eventNames(res)
|
||||||
|
want := []string{"admin.user.delete", "remote.screen.start", "web.login.failure", "web.login.success"}
|
||||||
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("events = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditQueryEventFilters(t *testing.T) {
|
||||||
|
path := writeAuditFixture(t, auditFixtureRecords())
|
||||||
|
cases := []struct {
|
||||||
|
filter string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"web.", []string{"web.login.failure", "web.login.success"}},
|
||||||
|
{"web.*", []string{"web.login.failure", "web.login.success"}},
|
||||||
|
{"web.login.failure", []string{"web.login.failure"}},
|
||||||
|
{"remote.*", []string{"remote.screen.start"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
res := runAuditQuery(t, path, auditQueryRequest{Event: tc.filter, Limit: 100})
|
||||||
|
got := eventNames(res)
|
||||||
|
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||||
|
t.Errorf("filter %q → %v, want %v", tc.filter, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditQueryFieldFilters(t *testing.T) {
|
||||||
|
path := writeAuditFixture(t, auditFixtureRecords())
|
||||||
|
from, _ := time.Parse(time.RFC3339, "2026-10-18T10:01:00Z")
|
||||||
|
to, _ := time.Parse(time.RFC3339, "2026-10-18T10:02:00Z")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
q auditQueryRequest
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"actor case-insensitive", auditQueryRequest{Actor: "ALICE", Limit: 100}, []string{"web.login.failure"}},
|
||||||
|
{"ip", auditQueryRequest{IP: "203.0.113.2", Limit: 100}, []string{"web.login.failure"}},
|
||||||
|
{"device", auditQueryRequest{DeviceID: "dev1", Limit: 100}, []string{"remote.screen.start"}},
|
||||||
|
{"warn only", auditQueryRequest{WarnOnly: true, Limit: 100}, []string{"web.login.failure"}},
|
||||||
|
{"keyword target", auditQueryRequest{Keyword: "BOB", Limit: 100}, []string{"admin.user.delete"}},
|
||||||
|
{"time range", auditQueryRequest{
|
||||||
|
From: from.Format(time.RFC3339), To: to.Format(time.RFC3339), Limit: 100,
|
||||||
|
}, []string{"remote.screen.start", "web.login.failure"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
res := runAuditQuery(t, path, tc.q)
|
||||||
|
got := eventNames(res)
|
||||||
|
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||||
|
t.Errorf("%s → %v, want %v", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditQueryOffsetAndLimit(t *testing.T) {
|
||||||
|
path := writeAuditFixture(t, auditFixtureRecords())
|
||||||
|
res := runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 1})
|
||||||
|
if res.Total != 4 {
|
||||||
|
t.Fatalf("total = %d, want 4", res.Total)
|
||||||
|
}
|
||||||
|
got := eventNames(res)
|
||||||
|
want := []string{"remote.screen.start", "web.login.failure"}
|
||||||
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("events = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offsetting past the end yields no events but keeps the total.
|
||||||
|
res = runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 4})
|
||||||
|
if res.Total != 4 || len(res.Events) != 0 {
|
||||||
|
t.Fatalf("offset past end: total=%d events=%v", res.Total, eventNames(res))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditQuerySkipsNonAuditAndTornLines(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "audit.log")
|
||||||
|
content := `{"event":"device.online","actor":"x"}
|
||||||
|
not json at all
|
||||||
|
{"level":"info","time":"2026-10-18T10:00:00Z","audit":true,"event":"web.login.success","actor":"admin"}`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res := runAuditQuery(t, path, auditQueryRequest{Limit: 100})
|
||||||
|
if res.Total != 1 || len(res.Events) != 1 {
|
||||||
|
t.Fatalf("total=%d events=%v, want the single valid record", res.Total, eventNames(res))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditQueryMissingFile(t *testing.T) {
|
||||||
|
res := runAuditQuery(t, filepath.Join(t.TempDir(), "does-not-exist.log"), auditQueryRequest{Limit: 10})
|
||||||
|
if res.FileExists {
|
||||||
|
t.Error("FileExists = true for a missing file")
|
||||||
|
}
|
||||||
|
if res.Total != 0 || len(res.Events) != 0 {
|
||||||
|
t.Errorf("total=%d events=%v, want empty", res.Total, eventNames(res))
|
||||||
|
}
|
||||||
|
if res.Events == nil {
|
||||||
|
t.Error("Events is nil; JSON would encode null instead of []")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditQueryClampsPaging(t *testing.T) {
|
||||||
|
if got := clampAuditLimit(0); got != auditQueryDefaultLimit {
|
||||||
|
t.Errorf("limit(0) = %d, want default %d", got, auditQueryDefaultLimit)
|
||||||
|
}
|
||||||
|
if got := clampAuditLimit(-3); got != auditQueryDefaultLimit {
|
||||||
|
t.Errorf("limit(-3) = %d, want default %d", got, auditQueryDefaultLimit)
|
||||||
|
}
|
||||||
|
if got := clampAuditLimit(9999); got != auditQueryMaxLimit {
|
||||||
|
t.Errorf("limit(9999) = %d, want max %d", got, auditQueryMaxLimit)
|
||||||
|
}
|
||||||
|
if got := clampAuditLimit(50); got != 50 {
|
||||||
|
t.Errorf("limit(50) = %d, want 50", got)
|
||||||
|
}
|
||||||
|
if got := clampAuditOffset(-5); got != 0 {
|
||||||
|
t.Errorf("offset(-5) = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if got := clampAuditOffset(1 << 30); got != auditQueryMaxOffset {
|
||||||
|
t.Errorf("offset(huge) = %d, want max %d", got, auditQueryMaxOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryAuditHandlerAdminOnly(t *testing.T) {
|
||||||
|
wh, _, _ := auditTestHub(t)
|
||||||
|
viewer, tok := auditTestClient(wh, "viewer")
|
||||||
|
raw, _ := json.Marshal(map[string]any{"token": tok})
|
||||||
|
wh.handleQueryAudit(viewer, raw)
|
||||||
|
|
||||||
|
msg := readQueuedJSON(t, viewer)
|
||||||
|
if msg["cmd"] != "audit_result" || msg["ok"] != false {
|
||||||
|
t.Fatalf("viewer reply = %+v", msg)
|
||||||
|
}
|
||||||
|
if _, ok := msg["events"]; ok {
|
||||||
|
t.Error("denied viewer got events")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryAuditHandlerReturnsEvents(t *testing.T) {
|
||||||
|
wh, _, _ := auditTestHub(t)
|
||||||
|
// Two records written directly through the same logger the hub queries.
|
||||||
|
wh.log.Audit("web.login.success", map[string]any{"actor": "admin", "ip": "203.0.113.1"})
|
||||||
|
wh.log.Audit("remote.screen.start", map[string]any{"actor": "admin", "device_id": "dev1"})
|
||||||
|
|
||||||
|
admin, tok := auditTestClient(wh, "admin")
|
||||||
|
raw, _ := json.Marshal(map[string]any{"token": tok, "limit": 10})
|
||||||
|
wh.handleQueryAudit(admin, raw)
|
||||||
|
|
||||||
|
msg := readQueuedJSON(t, admin)
|
||||||
|
if msg["cmd"] != "audit_result" || msg["ok"] != true {
|
||||||
|
t.Fatalf("reply = %+v", msg)
|
||||||
|
}
|
||||||
|
events, ok := msg["events"].([]any)
|
||||||
|
if !ok || len(events) < 2 {
|
||||||
|
t.Fatalf("events = %#v, want at least the two written records", msg["events"])
|
||||||
|
}
|
||||||
|
// requireAuth writes a web.session.resume record for this same request, so
|
||||||
|
// that one legitimately appears newer. Assert the two written records are
|
||||||
|
// present and still newest-first relative to each other.
|
||||||
|
var names []string
|
||||||
|
for _, e := range events {
|
||||||
|
if m, ok := e.(map[string]any); ok {
|
||||||
|
names = append(names, auditStringField(m, "event"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
joined := strings.Join(names, ",")
|
||||||
|
if !strings.Contains(joined, "remote.screen.start") || !strings.Contains(joined, "web.login.success") {
|
||||||
|
t.Fatalf("events = %v, want both written records", names)
|
||||||
|
}
|
||||||
|
if strings.Index(joined, "remote.screen.start") > strings.Index(joined, "web.login.success") {
|
||||||
|
t.Fatalf("events = %v, want newest-first order", names)
|
||||||
|
}
|
||||||
|
if msg["truncated"] != false || msg["file_exists"] != true {
|
||||||
|
t.Errorf("meta = %+v", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryAuditHandlerDisabledFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
wh, _ := newAuditTestHubWithoutAuditFile(t, dir)
|
||||||
|
admin, tok := auditTestClient(wh, "admin")
|
||||||
|
raw, _ := json.Marshal(map[string]any{"token": tok})
|
||||||
|
wh.handleQueryAudit(admin, raw)
|
||||||
|
|
||||||
|
msg := readQueuedJSON(t, admin)
|
||||||
|
if msg["ok"] != false {
|
||||||
|
t.Fatalf("reply = %+v, want ok:false when the audit file is disabled", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// auditTestHub builds a wsHub whose logger writes both the main log and the
|
||||||
|
// dedicated audit log to a temp directory.
|
||||||
|
func auditTestHub(t *testing.T) (wh *wsHub, devices *hub.Hub, auditPath string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
auditPath = filepath.Join(dir, "audit.log")
|
||||||
|
lg := logger.New(logger.Config{
|
||||||
|
Level: logger.LevelInfo,
|
||||||
|
Console: false,
|
||||||
|
File: filepath.Join(dir, "server.log"),
|
||||||
|
AuditFile: auditPath,
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { _ = lg.Close() })
|
||||||
|
|
||||||
|
devices = hub.New()
|
||||||
|
wh = newWSHub(wsauth.New(), devices, nil, lg)
|
||||||
|
t.Cleanup(wh.stop)
|
||||||
|
return wh, devices, auditPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditTestClient(wh *wsHub, role string) (*wsClient, string) {
|
||||||
|
tok, err := wh.auth.IssueToken("tester", role)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
c := &wsClient{
|
||||||
|
send: make(chan wsMsg, 64),
|
||||||
|
closed: make(chan struct{}),
|
||||||
|
id: newConnID(),
|
||||||
|
addr: "203.0.113.5",
|
||||||
|
peerAddr: "203.0.113.5",
|
||||||
|
connectedAt: time.Now(),
|
||||||
|
watchIDs: make(map[string]struct{}),
|
||||||
|
watchStartedAt: make(map[string]time.Time),
|
||||||
|
keyframeAt: make(map[string]int64),
|
||||||
|
}
|
||||||
|
wh.register(c)
|
||||||
|
return c, tok
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAuditEvents(t *testing.T, path string) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open audit log: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var out []map[string]any
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||||
|
t.Fatalf("bad audit line %q: %v", line, err)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
t.Fatalf("scan audit log: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAuditEvent(t *testing.T, path, event string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
for _, m := range readAuditEvents(t, path) {
|
||||||
|
if m["event"] == event {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("audit event %q not found in %v", event, readAuditEvents(t, path))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginFailureIsAudited(t *testing.T) {
|
||||||
|
wh, _, auditPath := auditTestHub(t)
|
||||||
|
wh.auth.AddAdminFromPlainPassword("admin", "hunter2")
|
||||||
|
c, _ := auditTestClient(wh, "viewer")
|
||||||
|
|
||||||
|
nonce, err := wsauth.NewNonce()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewNonce: %v", err)
|
||||||
|
}
|
||||||
|
c.nonce = nonce
|
||||||
|
raw, _ := json.Marshal(map[string]any{
|
||||||
|
"username": "admin",
|
||||||
|
"response": "not-the-right-response",
|
||||||
|
"nonce": nonce,
|
||||||
|
})
|
||||||
|
wh.handleLogin(c, raw)
|
||||||
|
|
||||||
|
rec := findAuditEvent(t, auditPath, auditLoginFail)
|
||||||
|
if rec["reason"] != "invalid_credentials" {
|
||||||
|
t.Errorf("reason = %v", rec["reason"])
|
||||||
|
}
|
||||||
|
if rec["username"] != "admin" {
|
||||||
|
t.Errorf("username = %v", rec["username"])
|
||||||
|
}
|
||||||
|
if rec["user_exists"] != true {
|
||||||
|
t.Errorf("user_exists = %v, want true", rec["user_exists"])
|
||||||
|
}
|
||||||
|
if rec["ip"] != "203.0.113.5" {
|
||||||
|
t.Errorf("ip = %v", rec["ip"])
|
||||||
|
}
|
||||||
|
if _, ok := rec["response"]; ok {
|
||||||
|
t.Error("audit record leaked the login response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminDenialIsAudited(t *testing.T) {
|
||||||
|
wh, _, auditPath := auditTestHub(t)
|
||||||
|
c, tok := auditTestClient(wh, "viewer")
|
||||||
|
raw, _ := json.Marshal(map[string]any{"token": tok})
|
||||||
|
|
||||||
|
if wh.requireAdmin(c, raw, "delete_device_result") {
|
||||||
|
t.Fatal("viewer passed requireAdmin")
|
||||||
|
}
|
||||||
|
rec := findAuditEvent(t, auditPath, auditDenied)
|
||||||
|
if rec["operation"] != "delete_device" {
|
||||||
|
t.Errorf("operation = %v", rec["operation"])
|
||||||
|
}
|
||||||
|
if rec["role"] != "viewer" || rec["actor"] != "tester" {
|
||||||
|
t.Errorf("identity fields = %+v", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenStartAndOfflineStopAreAudited(t *testing.T) {
|
||||||
|
wh, devices, auditPath := auditTestHub(t)
|
||||||
|
devices.SetSender(func(*connection.Context, []byte) error { return nil })
|
||||||
|
hctx := &connection.Context{RemoteAddr: "10.0.0.9:1234"}
|
||||||
|
devices.Register(&hub.Device{ID: "dev1", Name: "Dev"}, hctx)
|
||||||
|
|
||||||
|
c, tok := auditTestClient(wh, "admin")
|
||||||
|
raw, _ := json.Marshal(map[string]any{
|
||||||
|
"token": tok, "id": "dev1", "multiWatch": true, "req": "1",
|
||||||
|
})
|
||||||
|
wh.handleConnect(c, raw)
|
||||||
|
|
||||||
|
start := findAuditEvent(t, auditPath, auditScreenStart)
|
||||||
|
if start["device_id"] != "dev1" || start["mode"] != "started" {
|
||||||
|
t.Errorf("screen start = %+v", start)
|
||||||
|
}
|
||||||
|
|
||||||
|
wh.OnDeviceOffline("dev1")
|
||||||
|
|
||||||
|
stop := findAuditEvent(t, auditPath, auditScreenStop)
|
||||||
|
if stop["device_id"] != "dev1" || stop["reason"] != "device_offline" {
|
||||||
|
t.Errorf("screen stop = %+v", stop)
|
||||||
|
}
|
||||||
|
if _, ok := stop["duration_ms"]; !ok {
|
||||||
|
t.Error("screen stop has no duration_ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownCommandRecordedOnce(t *testing.T) {
|
||||||
|
wh, _, auditPath := auditTestHub(t)
|
||||||
|
c, _ := auditTestClient(wh, "viewer")
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
wh.dispatch(c, "definitely_not_a_command", []byte(`{}`))
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, m := range readAuditEvents(t, auditPath) {
|
||||||
|
if m["event"] == auditUnknownCmd {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("unknown-command audit records = %d, want 1", count)
|
||||||
|
}
|
||||||
|
if c.statUnknownCmd != 5 {
|
||||||
|
t.Fatalf("statUnknownCmd = %d, want 5", c.statUnknownCmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deleteTestHub builds the minimal wsHub wiring handleDeleteDevice needs.
|
||||||
|
func deleteTestHub(t *testing.T) (*wsHub, *hub.Hub, *devicehistory.Store) {
|
||||||
|
t.Helper()
|
||||||
|
h := hub.New()
|
||||||
|
hist, err := devicehistory.Open(filepath.Join(t.TempDir(), "devices.json"), 100, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open history: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = hist.Close() })
|
||||||
|
h.Subscribe(hist)
|
||||||
|
|
||||||
|
auth := wsauth.New()
|
||||||
|
wh := &wsHub{
|
||||||
|
auth: auth,
|
||||||
|
devices: h,
|
||||||
|
history: hist,
|
||||||
|
log: logger.New(logger.Config{}),
|
||||||
|
clients: make(map[*wsClient]struct{}),
|
||||||
|
}
|
||||||
|
return wh, h, hist
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteTestClient(wh *wsHub, role string) (*wsClient, string) {
|
||||||
|
tok, err := wh.auth.IssueToken("tester", role)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
c := &wsClient{send: make(chan wsMsg, 8), closed: make(chan struct{})}
|
||||||
|
wh.register(c)
|
||||||
|
return c, tok
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteReply(t *testing.T, c *wsClient) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case m := <-c.send:
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(m.data, &out); err != nil {
|
||||||
|
t.Fatalf("decode reply: %v", err)
|
||||||
|
}
|
||||||
|
if out["cmd"] == "delete_device_result" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("no delete_device_result reply")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteDeviceOfflineRemovesHistoryRecord(t *testing.T) {
|
||||||
|
wh, h, hist := deleteTestHub(t)
|
||||||
|
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
|
||||||
|
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
|
||||||
|
h.Unregister("gone")
|
||||||
|
|
||||||
|
c, tok := deleteTestClient(wh, "admin")
|
||||||
|
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
|
||||||
|
wh.handleDeleteDevice(c, raw)
|
||||||
|
|
||||||
|
rep := deleteReply(t, c)
|
||||||
|
if rep["ok"] != true || rep["mode"] != "removed" {
|
||||||
|
t.Fatalf("reply = %+v, want ok/removed", rep)
|
||||||
|
}
|
||||||
|
if hist.Remove("gone") {
|
||||||
|
t.Fatal("history record still present after delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteDeviceOnlineSendsUninstall(t *testing.T) {
|
||||||
|
wh, h, hist := deleteTestHub(t)
|
||||||
|
var sent []byte
|
||||||
|
h.SetSender(func(_ *connection.Context, data []byte) error {
|
||||||
|
sent = append([]byte(nil), data...)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
h.Register(&hub.Device{ID: "live", Name: "Live"},
|
||||||
|
&connection.Context{RemoteAddr: "10.0.0.2:1000"})
|
||||||
|
|
||||||
|
c, tok := deleteTestClient(wh, "admin")
|
||||||
|
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "live"})
|
||||||
|
wh.handleDeleteDevice(c, raw)
|
||||||
|
|
||||||
|
rep := deleteReply(t, c)
|
||||||
|
if rep["ok"] != true || rep["mode"] != "uninstall" {
|
||||||
|
t.Fatalf("reply = %+v, want ok/uninstall", rep)
|
||||||
|
}
|
||||||
|
if len(sent) != 1 || sent[0] != protocol.CommandUninstall {
|
||||||
|
t.Fatalf("sent = %v, want single %d byte", sent, protocol.CommandUninstall)
|
||||||
|
}
|
||||||
|
// The history record is dropped along with the uninstall, so the device
|
||||||
|
// does not reappear as a dead offline entry after it disconnects.
|
||||||
|
if hist.Remove("live") {
|
||||||
|
t.Fatal("online device's history record survived the uninstall")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteDeviceRequiresAdmin(t *testing.T) {
|
||||||
|
wh, h, hist := deleteTestHub(t)
|
||||||
|
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
|
||||||
|
&connection.Context{RemoteAddr: "10.0.0.3:1000"})
|
||||||
|
h.Unregister("gone")
|
||||||
|
|
||||||
|
c, tok := deleteTestClient(wh, "viewer")
|
||||||
|
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
|
||||||
|
wh.handleDeleteDevice(c, raw)
|
||||||
|
|
||||||
|
rep := deleteReply(t, c)
|
||||||
|
if rep["ok"] != false {
|
||||||
|
t.Fatalf("reply = %+v, want ok:false", rep)
|
||||||
|
}
|
||||||
|
if !hist.Remove("gone") {
|
||||||
|
t.Fatal("viewer delete removed the history record")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListDevicesWithOffline(t *testing.T) {
|
||||||
|
h := hub.New()
|
||||||
|
hist, err := devicehistory.Open(filepath.Join(t.TempDir(), "devices.json"), 100, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open history: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = hist.Close() })
|
||||||
|
h.Subscribe(hist)
|
||||||
|
|
||||||
|
h.Register(&hub.Device{ID: "online", Name: "Online"},
|
||||||
|
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
|
||||||
|
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
|
||||||
|
&connection.Context{RemoteAddr: "10.0.0.2:1000"})
|
||||||
|
h.Unregister("gone")
|
||||||
|
|
||||||
|
wh := &wsHub{devices: h, history: hist}
|
||||||
|
got := wh.listDevicesWithOffline()
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("devices = %d, want 2 (1 online + 1 offline)", len(got))
|
||||||
|
}
|
||||||
|
if got[0].ID != "online" || !got[0].Online {
|
||||||
|
t.Fatalf("first = %+v, want online device", got[0])
|
||||||
|
}
|
||||||
|
if got[1].ID != "gone" || got[1].Online {
|
||||||
|
t.Fatalf("second = %+v, want offline device", got[1])
|
||||||
|
}
|
||||||
|
if got[1].LastSeen == 0 {
|
||||||
|
t.Fatal("offline device missing last_seen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDevicesWithoutHistoryIsOnlineOnly(t *testing.T) {
|
||||||
|
h := hub.New()
|
||||||
|
h.Register(&hub.Device{ID: "online", Name: "Online"},
|
||||||
|
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
|
||||||
|
|
||||||
|
wh := &wsHub{devices: h}
|
||||||
|
got := wh.listDevicesWithOffline()
|
||||||
|
if len(got) != 1 || got[0].ID != "online" {
|
||||||
|
t.Fatalf("devices = %+v, want just the online device", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -16,6 +16,7 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||||
@@ -30,6 +31,7 @@ type Server struct {
|
|||||||
srv *http.Server
|
srv *http.Server
|
||||||
hub *hub.Hub
|
hub *hub.Hub
|
||||||
auth *wsauth.Authenticator
|
auth *wsauth.Authenticator
|
||||||
|
history *devicehistory.Store // nil → no offline devices
|
||||||
ws *wsHub
|
ws *wsHub
|
||||||
allowedOrigins []string // for WS Origin allowlist; empty = same-origin only
|
allowedOrigins []string // for WS Origin allowlist; empty = same-origin only
|
||||||
loginIPLimit *wsauth.RateLimiter
|
loginIPLimit *wsauth.RateLimiter
|
||||||
@@ -86,6 +88,14 @@ func New(port int, log *logger.Logger, h *hub.Hub, auth *wsauth.Authenticator) *
|
|||||||
return &Server{port: port, log: log, hub: h, auth: auth}
|
return &Server{port: port, log: log, hub: h, auth: auth}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithDeviceHistory attaches the persistent device-history store. When set,
|
||||||
|
// device_list also returns devices that have logged in before but are offline
|
||||||
|
// now. Passing nil keeps the online-only behaviour. Safe to call before Start.
|
||||||
|
func (s *Server) WithDeviceHistory(h *devicehistory.Store) *Server {
|
||||||
|
s.history = h
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// WithConfig applies hardening configuration. Returns the receiver for
|
// WithConfig applies hardening configuration. Returns the receiver for
|
||||||
// chainable setup. Safe to call before Start; ignored thereafter.
|
// chainable setup. Safe to call before Start; ignored thereafter.
|
||||||
func (s *Server) WithConfig(cfg Config) *Server {
|
func (s *Server) WithConfig(cfg Config) *Server {
|
||||||
@@ -107,7 +117,7 @@ func (s *Server) Start() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
s.ws = newWSHub(s.auth, s.hub, s.log).
|
s.ws = newWSHub(s.auth, s.hub, s.history, s.log).
|
||||||
withOriginAllowlist(s.allowedOrigins).
|
withOriginAllowlist(s.allowedOrigins).
|
||||||
withLoginRateLimiters(s.loginIPLimit, s.loginUserLimit).
|
withLoginRateLimiters(s.loginIPLimit, s.loginUserLimit).
|
||||||
withTrustForwardedFor(s.trustForwardedFor)
|
withTrustForwardedFor(s.trustForwardedFor)
|
||||||
|
|||||||
+23
-2
@@ -33,26 +33,47 @@ func (s *Server) handleSSO(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
username, role, ok := s.sso.Detect(r)
|
username, role, ok := s.sso.Detect(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
// No usable portal credential: the normal fallback to the login form.
|
||||||
|
// Not audited — portal users without YAMA access hit this routinely.
|
||||||
s.unauthorized(w)
|
s.unauthorized(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Only the "admin" role may skip the login form via SSO. Every other role
|
// Only the "admin" role may skip the login form via SSO. Every other role
|
||||||
// (viewer, unknown) must authenticate against YAMA's own login, even with
|
// (viewer, unknown) must authenticate against YAMA's own login, even with
|
||||||
// a valid portal session.
|
// a valid portal session. A valid portal identity being refused here is
|
||||||
|
// worth an audit record: it is an authorization decision, not a login-form
|
||||||
|
// fallback.
|
||||||
if role != "admin" {
|
if role != "admin" {
|
||||||
|
fields := requestFields(r, s.trustForwardedFor)
|
||||||
|
fields["method"] = "sso"
|
||||||
|
fields["actor"] = username
|
||||||
|
fields["role"] = role
|
||||||
|
fields["reason"] = "role_not_allowed"
|
||||||
|
s.log.AuditWarn(auditLoginFail, fields)
|
||||||
s.unauthorized(w)
|
s.unauthorized(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := s.auth.IssueToken(username, role)
|
token, err := s.auth.IssueToken(username, role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fields := requestFields(r, s.trustForwardedFor)
|
||||||
|
fields["method"] = "sso"
|
||||||
|
fields["actor"] = username
|
||||||
|
fields["role"] = role
|
||||||
|
fields["reason"] = "token_issue_failed"
|
||||||
|
fields["error"] = err.Error()
|
||||||
|
s.log.AuditWarn(auditLoginFail, fields)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = w.Write([]byte(`{"error":"internal error"}`))
|
_, _ = w.Write([]byte(`{"error":"internal error"}`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("sso: user=%s role=%s", username, role)
|
fields := requestFields(r, s.trustForwardedFor)
|
||||||
|
fields["method"] = "sso"
|
||||||
|
fields["actor"] = username
|
||||||
|
fields["role"] = role
|
||||||
|
s.log.Audit(auditLoginOK, fields)
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
|||||||
+130
-17
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||||
@@ -47,11 +48,19 @@ type wsClient struct {
|
|||||||
closed chan struct{}
|
closed chan struct{}
|
||||||
once sync.Once
|
once sync.Once
|
||||||
|
|
||||||
|
// Immutable connection metadata (set once in serve).
|
||||||
|
id string // short random id for audit correlation
|
||||||
|
addr string // resolved client IP for logs / rate limits
|
||||||
|
peerAddr string // actual TCP peer (differs from addr behind a proxy)
|
||||||
|
origin string
|
||||||
|
userAgent string
|
||||||
|
connectedAt time.Time
|
||||||
|
|
||||||
// Mutated under wsHub.mu (or only by the read loop owning this client).
|
// Mutated under wsHub.mu (or only by the read loop owning this client).
|
||||||
nonce string // outstanding challenge — cleared after a successful login
|
nonce string // outstanding challenge — cleared after a successful login
|
||||||
token string // set once authenticated
|
token string // set once authenticated
|
||||||
role string // mirrors session role after login
|
role string // mirrors session role after login
|
||||||
addr string // client address for logs
|
user string // mirrors session username after login/resume
|
||||||
termWatching string // device ID for an open web terminal session, "" otherwise
|
termWatching string // device ID for an open web terminal session, "" otherwise
|
||||||
|
|
||||||
// watchIDs is the set of devices this browser is streaming (the screen
|
// watchIDs is the set of devices this browser is streaming (the screen
|
||||||
@@ -59,9 +68,30 @@ type wsClient struct {
|
|||||||
// Guarded by wsHub.mu.
|
// Guarded by wsHub.mu.
|
||||||
watchIDs map[string]struct{}
|
watchIDs map[string]struct{}
|
||||||
|
|
||||||
|
// watchStartedAt records when each watch began so stop events can carry a
|
||||||
|
// duration. Guarded by wsHub.mu.
|
||||||
|
watchStartedAt map[string]time.Time
|
||||||
|
|
||||||
// keyframeAt throttles keyframe requests to one per device per second.
|
// keyframeAt throttles keyframe requests to one per device per second.
|
||||||
// Unix seconds, guarded by wsHub.mu.
|
// Unix seconds, guarded by wsHub.mu.
|
||||||
keyframeAt map[string]int64
|
keyframeAt map[string]int64
|
||||||
|
|
||||||
|
// Terminal session bookkeeping. Guarded by wsHub.mu.
|
||||||
|
termStartedAt time.Time
|
||||||
|
termCloseReason string
|
||||||
|
|
||||||
|
// Audit counters, owned by the read-loop goroutine and read in unregister
|
||||||
|
// after that goroutine returns.
|
||||||
|
statCommands int
|
||||||
|
statInvalidToken int
|
||||||
|
statLoginFailures int
|
||||||
|
statAdminDenied int
|
||||||
|
statUnknownSalt int
|
||||||
|
statUnknownCmd int
|
||||||
|
|
||||||
|
auditInvalidTokenLogged bool
|
||||||
|
auditUnknownSaltLogged bool
|
||||||
|
auditUnknownCmdLogged bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// queue writes a JSON text frame onto the send buffer. Drops silently if the
|
// queue writes a JSON text frame onto the send buffer. Drops silently if the
|
||||||
@@ -96,7 +126,7 @@ func (c *wsClient) close() {
|
|||||||
// challengeCaps advertises optional protocol features. It is appended to every
|
// challengeCaps advertises optional protocol features. It is appended to every
|
||||||
// challenge, including the rotation after a failed login, because the browser
|
// challenge, including the rotation after a failed login, because the browser
|
||||||
// overwrites its capability state on each challenge. Old frontends ignore it.
|
// overwrites its capability state on each challenge. Old frontends ignore it.
|
||||||
const challengeCaps = `,"caps":{"multiWatch":true,"keyframeRequest":true}`
|
const challengeCaps = `,"caps":{"multiWatch":true,"keyframeRequest":true,"offlineDevices":true,"auditLog":true}`
|
||||||
|
|
||||||
// maxWatchPerClient bounds the number of devices one WebSocket may stream at
|
// maxWatchPerClient bounds the number of devices one WebSocket may stream at
|
||||||
// once. The wall tops out at 25 tiles; the slack covers a focus switch racing
|
// once. The wall tops out at 25 tiles; the slack covers a focus switch racing
|
||||||
@@ -116,6 +146,7 @@ func (c *wsClient) isWatching(deviceID string) bool {
|
|||||||
type wsHub struct {
|
type wsHub struct {
|
||||||
auth *wsauth.Authenticator
|
auth *wsauth.Authenticator
|
||||||
devices *hub.Hub
|
devices *hub.Hub
|
||||||
|
history *devicehistory.Store // nil → device_list stays online-only
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -138,10 +169,11 @@ type wsHub struct {
|
|||||||
trustForwardedFor bool // honor X-Forwarded-For (only when behind a trusted proxy)
|
trustForwardedFor bool // honor X-Forwarded-For (only when behind a trusted proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, log *logger.Logger) *wsHub {
|
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, history *devicehistory.Store, log *logger.Logger) *wsHub {
|
||||||
h := &wsHub{
|
h := &wsHub{
|
||||||
auth: auth,
|
auth: auth,
|
||||||
devices: devices,
|
devices: devices,
|
||||||
|
history: history,
|
||||||
log: log,
|
log: log,
|
||||||
clients: make(map[*wsClient]struct{}),
|
clients: make(map[*wsClient]struct{}),
|
||||||
screenCloseTimers: make(map[string]*time.Timer),
|
screenCloseTimers: make(map[string]*time.Timer),
|
||||||
@@ -283,8 +315,12 @@ func (h *wsHub) OnDeviceOffline(id string) {
|
|||||||
// connection no longer holds a subscription for it. The browser does the
|
// connection no longer holds a subscription for it. The browser does the
|
||||||
// same with its local refcount. Without this, a stale entry would keep
|
// same with its local refcount. Without this, a stale entry would keep
|
||||||
// countWatchers non-zero and later block the screen teardown.
|
// countWatchers non-zero and later block the screen teardown.
|
||||||
|
type viewer struct {
|
||||||
|
c *wsClient
|
||||||
|
start time.Time
|
||||||
|
}
|
||||||
|
var stopped []viewer
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
|
||||||
for c := range h.clients {
|
for c := range h.clients {
|
||||||
if !c.isWatching(id) {
|
if !c.isWatching(id) {
|
||||||
continue
|
continue
|
||||||
@@ -294,6 +330,19 @@ func (h *wsHub) OnDeviceOffline(id string) {
|
|||||||
if c.token != "" {
|
if c.token != "" {
|
||||||
c.queue(msg)
|
c.queue(msg)
|
||||||
}
|
}
|
||||||
|
if !c.watchStartedAt[id].IsZero() {
|
||||||
|
stopped = append(stopped, viewer{c, c.watchStartedAt[id]})
|
||||||
|
}
|
||||||
|
delete(c.watchStartedAt, id)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
// Audit outside h.mu: h.audit takes h.mu.RLock.
|
||||||
|
for _, v := range stopped {
|
||||||
|
h.audit(v.c, false, auditScreenStop, map[string]any{
|
||||||
|
"device_id": id,
|
||||||
|
"duration_ms": durationMs(v.start),
|
||||||
|
"reason": "device_offline",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,14 +461,35 @@ func (h *wsHub) OnTerminalClosed(deviceID string, reason string) {
|
|||||||
"ok": true,
|
"ok": true,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
})
|
})
|
||||||
|
type closed struct {
|
||||||
|
c *wsClient
|
||||||
|
start time.Time
|
||||||
|
why string
|
||||||
|
}
|
||||||
|
var done []closed
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
|
||||||
for c := range h.clients {
|
for c := range h.clients {
|
||||||
if c.termWatching == deviceID && c.token != "" {
|
if c.termWatching == deviceID && c.token != "" {
|
||||||
|
why := c.termCloseReason
|
||||||
|
if why == "" {
|
||||||
|
why = reason
|
||||||
|
}
|
||||||
|
done = append(done, closed{c, c.termStartedAt, why})
|
||||||
c.termWatching = ""
|
c.termWatching = ""
|
||||||
|
c.termStartedAt = time.Time{}
|
||||||
|
c.termCloseReason = ""
|
||||||
c.queue(msg)
|
c.queue(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
// Audit outside h.mu: h.audit takes h.mu.RLock.
|
||||||
|
for _, d := range done {
|
||||||
|
h.audit(d.c, false, auditTermStop, map[string]any{
|
||||||
|
"device_id": deviceID,
|
||||||
|
"duration_ms": durationMs(d.start),
|
||||||
|
"reason": d.why,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnDeviceUpdate forwards heartbeat-derived liveness data so the device-list
|
// OnDeviceUpdate forwards heartbeat-derived liveness data so the device-list
|
||||||
@@ -464,26 +534,49 @@ func (h *wsHub) unregister(c *wsClient) {
|
|||||||
// below must run OUTSIDE the lock: countWatchers takes h.mu.RLock and
|
// below must run OUTSIDE the lock: countWatchers takes h.mu.RLock and
|
||||||
// deferredCloseScreen takes h.mu.Lock.
|
// deferredCloseScreen takes h.mu.Lock.
|
||||||
watched := make([]string, 0, len(c.watchIDs))
|
watched := make([]string, 0, len(c.watchIDs))
|
||||||
|
started := make(map[string]time.Time, len(c.watchIDs))
|
||||||
for id := range c.watchIDs {
|
for id := range c.watchIDs {
|
||||||
watched = append(watched, id)
|
watched = append(watched, id)
|
||||||
|
started[id] = c.watchStartedAt[id]
|
||||||
}
|
}
|
||||||
c.watchIDs = nil
|
c.watchIDs = nil
|
||||||
|
c.watchStartedAt = nil
|
||||||
c.keyframeAt = nil
|
c.keyframeAt = nil
|
||||||
|
|
||||||
|
termDevice := c.termWatching
|
||||||
|
termStart := c.termStartedAt
|
||||||
|
c.termWatching = ""
|
||||||
|
c.termStartedAt = time.Time{}
|
||||||
|
c.termCloseReason = ""
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
// If this client was the last viewer of a device, tear down the screen
|
// If this client was the last viewer of a device, tear down the screen
|
||||||
// session so the device stops encoding.
|
// session so the device stops encoding.
|
||||||
for _, id := range watched {
|
for _, id := range watched {
|
||||||
|
if !started[id].IsZero() {
|
||||||
|
h.audit(c, false, auditScreenStop, map[string]any{
|
||||||
|
"device_id": id,
|
||||||
|
"duration_ms": durationMs(started[id]),
|
||||||
|
"reason": "ws_close",
|
||||||
|
})
|
||||||
|
}
|
||||||
if h.countWatchers(id) == 0 {
|
if h.countWatchers(id) == 0 {
|
||||||
h.deferredCloseScreen(id)
|
h.deferredCloseScreen(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Terminal sessions are single-viewer by design, so any open session
|
// Terminal sessions are single-viewer by design, so any open session
|
||||||
// belongs to this client. Tear it down so the next viewer doesn't
|
// belongs to this client. Tear it down so the next viewer doesn't
|
||||||
// hit ErrTerminalBusy from an abandoned session.
|
// hit ErrTerminalBusy from an abandoned session. The client is already
|
||||||
if c.termWatching != "" {
|
// out of h.clients, so OnTerminalClosed won't reach it — audit here.
|
||||||
h.devices.CloseTerminalSession(c.termWatching)
|
if termDevice != "" {
|
||||||
c.termWatching = ""
|
h.devices.CloseTerminalSession(termDevice)
|
||||||
|
h.audit(c, false, auditTermStop, map[string]any{
|
||||||
|
"device_id": termDevice,
|
||||||
|
"duration_ms": durationMs(termStart),
|
||||||
|
"reason": "ws_close",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
h.auditClose(c, len(watched))
|
||||||
// Do NOT revoke the token: tokens are session-scoped, not WS-scoped.
|
// Do NOT revoke the token: tokens are session-scoped, not WS-scoped.
|
||||||
// Frontend may close+reopen the WS at any time (visibilitychange handler,
|
// Frontend may close+reopen the WS at any time (visibilitychange handler,
|
||||||
// brief network blip, reload) and must be able to resume with the same
|
// brief network blip, reload) and must be able to resume with the same
|
||||||
@@ -518,6 +611,16 @@ func (h *wsHub) deferredCloseScreen(deviceID string) {
|
|||||||
// ----- HTTP handler -------------------------------------------------------
|
// ----- HTTP handler -------------------------------------------------------
|
||||||
|
|
||||||
func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
|
func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Reject disallowed origins explicitly so the rejection is auditable.
|
||||||
|
// Upgrader.CheckOrigin below is kept as defense in depth.
|
||||||
|
if !h.checkOrigin(r) {
|
||||||
|
fields := requestFields(r, h.trustForwardedFor)
|
||||||
|
fields["reason"] = "origin_not_allowed"
|
||||||
|
h.log.AuditWarn(auditWSReject, fields)
|
||||||
|
http.Error(w, "origin not allowed", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Build a per-call upgrader so CheckOrigin closes over this hub's
|
// Build a per-call upgrader so CheckOrigin closes over this hub's
|
||||||
// allowlist instead of a package-level mutable.
|
// allowlist instead of a package-level mutable.
|
||||||
upgrader := baseUpgrader
|
upgrader := baseUpgrader
|
||||||
@@ -525,7 +628,10 @@ func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("ws upgrade: %v", err)
|
fields := requestFields(r, h.trustForwardedFor)
|
||||||
|
fields["reason"] = "upgrade_failed"
|
||||||
|
fields["error"] = err.Error()
|
||||||
|
h.log.AuditWarn(auditWSReject, fields)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
conn.SetReadLimit(wsReadLimit)
|
conn.SetReadLimit(wsReadLimit)
|
||||||
@@ -538,15 +644,22 @@ func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := &wsClient{
|
client := &wsClient{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
send: make(chan wsMsg, wsSendBuffer),
|
send: make(chan wsMsg, wsSendBuffer),
|
||||||
closed: make(chan struct{}),
|
closed: make(chan struct{}),
|
||||||
nonce: nonce,
|
id: newConnID(),
|
||||||
addr: clientIP(r, h.trustForwardedFor),
|
addr: clientIP(r, h.trustForwardedFor),
|
||||||
watchIDs: make(map[string]struct{}),
|
peerAddr: stripPort(r.RemoteAddr),
|
||||||
keyframeAt: make(map[string]int64),
|
origin: r.Header.Get("Origin"),
|
||||||
|
userAgent: r.UserAgent(),
|
||||||
|
connectedAt: time.Now(),
|
||||||
|
nonce: nonce,
|
||||||
|
watchIDs: make(map[string]struct{}),
|
||||||
|
watchStartedAt: make(map[string]time.Time),
|
||||||
|
keyframeAt: make(map[string]int64),
|
||||||
}
|
}
|
||||||
h.register(client)
|
h.register(client)
|
||||||
|
h.audit(client, false, auditWSConnect, client.wsRequestFields())
|
||||||
defer h.unregister(client)
|
defer h.unregister(client)
|
||||||
|
|
||||||
go h.writeLoop(client)
|
go h.writeLoop(client)
|
||||||
|
|||||||
+388
-20
@@ -2,7 +2,9 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
@@ -37,6 +39,9 @@ func (h *wsHub) rotateChallenge(c *wsClient) {
|
|||||||
// Phase 6 adds: term_open / term_input / term_resize / term_close (PTY relay).
|
// Phase 6 adds: term_open / term_input / term_resize / term_close (PTY relay).
|
||||||
// Phase 7 covers admin: create_user / delete_user / list_users / get_groups.
|
// Phase 7 covers admin: create_user / delete_user / list_users / get_groups.
|
||||||
func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
||||||
|
// Connection-scoped counters feed the web.ws.close audit summary, which
|
||||||
|
// is what turns a silent port scan into a visible event.
|
||||||
|
c.statCommands++
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "get_salt":
|
case "get_salt":
|
||||||
h.handleGetSalt(c, raw)
|
h.handleGetSalt(c, raw)
|
||||||
@@ -82,6 +87,8 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
|||||||
h.handleTermClose(c, raw)
|
h.handleTermClose(c, raw)
|
||||||
|
|
||||||
// Admin operations (Phase 7).
|
// Admin operations (Phase 7).
|
||||||
|
case "delete_device":
|
||||||
|
h.handleDeleteDevice(c, raw)
|
||||||
case "create_user":
|
case "create_user":
|
||||||
h.handleCreateUser(c, raw)
|
h.handleCreateUser(c, raw)
|
||||||
case "delete_user":
|
case "delete_user":
|
||||||
@@ -90,6 +97,19 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
|||||||
h.handleListUsers(c, raw)
|
h.handleListUsers(c, raw)
|
||||||
case "get_groups":
|
case "get_groups":
|
||||||
h.handleGetGroups(c, raw)
|
h.handleGetGroups(c, raw)
|
||||||
|
case "query_audit":
|
||||||
|
h.handleQueryAudit(c, raw)
|
||||||
|
default:
|
||||||
|
// Unknown commands are attacker-controlled and can be sent in a tight
|
||||||
|
// loop. Count them for the close summary and record only the first
|
||||||
|
// one per connection so the audit trail cannot be flooded.
|
||||||
|
c.statUnknownCmd++
|
||||||
|
if !c.auditUnknownCmdLogged {
|
||||||
|
c.auditUnknownCmdLogged = true
|
||||||
|
h.audit(c, true, auditUnknownCmd, map[string]any{
|
||||||
|
"command": cmd,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +122,11 @@ func (h *wsHub) requireAdmin(c *wsClient, raw []byte, replyCmd string) (ok bool)
|
|||||||
}
|
}
|
||||||
// c.role is cached on first successful requireAuth; safe to read here.
|
// c.role is cached on first successful requireAuth; safe to read here.
|
||||||
if c.role != "admin" {
|
if c.role != "admin" {
|
||||||
|
c.statAdminDenied++
|
||||||
|
h.audit(c, true, auditDenied, map[string]any{
|
||||||
|
"operation": strings.TrimSuffix(replyCmd, "_result"),
|
||||||
|
"reason": "not_admin",
|
||||||
|
})
|
||||||
c.queue(mustJSON(map[string]any{
|
c.queue(mustJSON(map[string]any{
|
||||||
"cmd": replyCmd, "ok": false, "msg": "Permission denied",
|
"cmd": replyCmd, "ok": false, "msg": "Permission denied",
|
||||||
}))
|
}))
|
||||||
@@ -130,7 +155,21 @@ func (h *wsHub) handleGetSalt(c *wsClient, raw []byte) {
|
|||||||
}
|
}
|
||||||
_ = json.Unmarshal(raw, &in)
|
_ = json.Unmarshal(raw, &in)
|
||||||
|
|
||||||
salt, _ := h.auth.GetSalt(in.Username)
|
salt, exists := h.auth.GetSalt(in.Username)
|
||||||
|
// The response deliberately hides whether the user exists (fake salt),
|
||||||
|
// but the audit trail should not: repeated probes for a nonexistent
|
||||||
|
// account are a strong signal of username enumeration. Record the first
|
||||||
|
// probe per connection only; the close summary carries the total.
|
||||||
|
if !exists {
|
||||||
|
c.statUnknownSalt++
|
||||||
|
if !c.auditUnknownSaltLogged {
|
||||||
|
c.auditUnknownSaltLogged = true
|
||||||
|
h.audit(c, true, auditSaltProbe, map[string]any{
|
||||||
|
"username": in.Username,
|
||||||
|
"reason": "unknown_user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
// GetSalt now returns a deterministic fake salt (16 hex chars) for
|
// GetSalt now returns a deterministic fake salt (16 hex chars) for
|
||||||
// unknown users — same shape as a real salt — so an attacker can't
|
// unknown users — same shape as a real salt — so an attacker can't
|
||||||
// tell from this response alone whether the username exists.
|
// tell from this response alone whether the username exists.
|
||||||
@@ -148,6 +187,8 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
|||||||
Nonce string `json:"nonce"`
|
Nonce string `json:"nonce"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &in); err != nil {
|
if err := json.Unmarshal(raw, &in); err != nil {
|
||||||
|
c.statLoginFailures++
|
||||||
|
h.audit(c, true, auditLoginFail, map[string]any{"reason": "malformed_request"})
|
||||||
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid request"}))
|
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid request"}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -158,7 +199,11 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
|||||||
// known account (admin). Either dimension tripping rejects the call
|
// known account (admin). Either dimension tripping rejects the call
|
||||||
// with a uniform "credentials" error so the limit is not detectable.
|
// with a uniform "credentials" error so the limit is not detectable.
|
||||||
if !h.allowLoginByIP(c) || !h.allowLoginByUsername(in.Username) {
|
if !h.allowLoginByIP(c) || !h.allowLoginByUsername(in.Username) {
|
||||||
h.log.Warn("ws login throttled: user=%s addr=%s", in.Username, c.addr)
|
c.statLoginFailures++
|
||||||
|
h.audit(c, true, auditLoginThrot, map[string]any{
|
||||||
|
"username": in.Username,
|
||||||
|
"reason": "rate_limited",
|
||||||
|
})
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"}))
|
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"}))
|
||||||
// Rotate the challenge: burns the previous nonce (replay protection)
|
// Rotate the challenge: burns the previous nonce (replay protection)
|
||||||
@@ -171,6 +216,11 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
|||||||
// Bind the response to the challenge we issued at connect time so that
|
// Bind the response to the challenge we issued at connect time so that
|
||||||
// replays from a different connection can't reuse a captured response.
|
// replays from a different connection can't reuse a captured response.
|
||||||
if in.Nonce == "" || in.Nonce != c.nonce {
|
if in.Nonce == "" || in.Nonce != c.nonce {
|
||||||
|
c.statLoginFailures++
|
||||||
|
h.audit(c, true, auditLoginFail, map[string]any{
|
||||||
|
"username": in.Username,
|
||||||
|
"reason": "invalid_challenge",
|
||||||
|
})
|
||||||
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid challenge"}))
|
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid challenge"}))
|
||||||
h.rotateChallenge(c)
|
h.rotateChallenge(c)
|
||||||
return
|
return
|
||||||
@@ -178,6 +228,16 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
|||||||
|
|
||||||
token, role, err := h.auth.VerifyLogin(in.Username, in.Response, in.Nonce)
|
token, role, err := h.auth.VerifyLogin(in.Username, in.Response, in.Nonce)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Record whether the account exists — an attack that keeps hammering
|
||||||
|
// a real account (or scans for names) reads very differently from a
|
||||||
|
// user mistyping their own password.
|
||||||
|
_, exists := h.auth.GetSalt(in.Username)
|
||||||
|
c.statLoginFailures++
|
||||||
|
h.audit(c, true, auditLoginFail, map[string]any{
|
||||||
|
"username": in.Username,
|
||||||
|
"reason": "invalid_credentials",
|
||||||
|
"user_exists": exists,
|
||||||
|
})
|
||||||
// Fixed delay on failure: makes online brute force impractical
|
// Fixed delay on failure: makes online brute force impractical
|
||||||
// even within the rate-limit budget, and erases the timing
|
// even within the rate-limit budget, and erases the timing
|
||||||
// difference between "wrong password" and "wrong nonce".
|
// difference between "wrong password" and "wrong nonce".
|
||||||
@@ -190,10 +250,20 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
|
|||||||
// user who fat-fingered a few times doesn't stay throttled.
|
// user who fat-fingered a few times doesn't stay throttled.
|
||||||
h.resetLoginThrottle(c, in.Username)
|
h.resetLoginThrottle(c, in.Username)
|
||||||
|
|
||||||
|
// Identity fields are read by audit() from hub goroutines, so publish
|
||||||
|
// them under h.mu.
|
||||||
|
h.mu.Lock()
|
||||||
c.nonce = ""
|
c.nonce = ""
|
||||||
c.token = token
|
c.token = token
|
||||||
c.role = role
|
c.role = role
|
||||||
h.log.Info("ws login: user=%s role=%s addr=%s", in.Username, role, c.addr)
|
c.user = in.Username
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
fields := c.wsRequestFields()
|
||||||
|
fields["method"] = "password"
|
||||||
|
fields["actor"] = in.Username
|
||||||
|
fields["role"] = role
|
||||||
|
h.audit(c, false, auditLoginOK, fields)
|
||||||
|
|
||||||
c.queue(mustJSON(map[string]any{
|
c.queue(mustJSON(map[string]any{
|
||||||
"cmd": "login_result",
|
"cmd": "login_result",
|
||||||
@@ -254,6 +324,7 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) {
|
|||||||
// Take h.mu so event-handler readers (OnResolutionChange/OnScreenFrame)
|
// Take h.mu so event-handler readers (OnResolutionChange/OnScreenFrame)
|
||||||
// get a consistent view of the watch set.
|
// get a consistent view of the watch set.
|
||||||
var closed []string
|
var closed []string
|
||||||
|
starts := make(map[string]time.Time)
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
if in.MultiWatch && in.ID != "" {
|
if in.MultiWatch && in.ID != "" {
|
||||||
// New frontend: drop only this device; the other wall tiles keep
|
// New frontend: drop only this device; the other wall tiles keep
|
||||||
@@ -261,6 +332,8 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) {
|
|||||||
if c.isWatching(in.ID) {
|
if c.isWatching(in.ID) {
|
||||||
delete(c.watchIDs, in.ID)
|
delete(c.watchIDs, in.ID)
|
||||||
delete(c.keyframeAt, in.ID)
|
delete(c.keyframeAt, in.ID)
|
||||||
|
starts[in.ID] = c.watchStartedAt[in.ID]
|
||||||
|
delete(c.watchStartedAt, in.ID)
|
||||||
closed = append(closed, in.ID)
|
closed = append(closed, in.ID)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -268,9 +341,11 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) {
|
|||||||
// one device, so clear everything.
|
// one device, so clear everything.
|
||||||
for id := range c.watchIDs {
|
for id := range c.watchIDs {
|
||||||
closed = append(closed, id)
|
closed = append(closed, id)
|
||||||
|
starts[id] = c.watchStartedAt[id]
|
||||||
}
|
}
|
||||||
c.watchIDs = make(map[string]struct{})
|
c.watchIDs = make(map[string]struct{})
|
||||||
c.keyframeAt = make(map[string]int64)
|
c.keyframeAt = make(map[string]int64)
|
||||||
|
c.watchStartedAt = make(map[string]time.Time)
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
@@ -285,6 +360,13 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) {
|
|||||||
|
|
||||||
// deferredCloseScreen takes h.mu, so it must run outside the lock above.
|
// deferredCloseScreen takes h.mu, so it must run outside the lock above.
|
||||||
for _, id := range closed {
|
for _, id := range closed {
|
||||||
|
if !starts[id].IsZero() {
|
||||||
|
h.audit(c, false, auditScreenStop, map[string]any{
|
||||||
|
"device_id": id,
|
||||||
|
"duration_ms": durationMs(starts[id]),
|
||||||
|
"reason": "disconnect",
|
||||||
|
})
|
||||||
|
}
|
||||||
if h.countWatchers(id) == 0 {
|
if h.countWatchers(id) == 0 {
|
||||||
h.deferredCloseScreen(id)
|
h.deferredCloseScreen(id)
|
||||||
}
|
}
|
||||||
@@ -390,8 +472,10 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var stopped []string
|
var stopped []string
|
||||||
var tooMany bool
|
starts := make(map[string]time.Time)
|
||||||
|
var tooMany, already bool
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
_, already = c.watchIDs[in.ID]
|
||||||
stopped, tooMany = applyConnectWatch(c, in.ID, in.MultiWatch)
|
stopped, tooMany = applyConnectWatch(c, in.ID, in.MultiWatch)
|
||||||
// A pending deferred close means the previous session is still alive for
|
// A pending deferred close means the previous session is still alive for
|
||||||
// a few seconds; cancel it so the cached resolution + IDR hot path wins.
|
// a few seconds; cancel it so the cached resolution + IDR hot path wins.
|
||||||
@@ -403,10 +487,24 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
|
|||||||
t.Stop()
|
t.Stop()
|
||||||
delete(h.screenCloseTimers, in.ID)
|
delete(h.screenCloseTimers, in.ID)
|
||||||
}
|
}
|
||||||
|
// Repeated connect (focus switch / wall re-subscribe) keeps the
|
||||||
|
// original start time so its audit duration covers the whole watch.
|
||||||
|
if _, ok := c.watchStartedAt[in.ID]; !ok {
|
||||||
|
c.watchStartedAt[in.ID] = time.Now()
|
||||||
|
}
|
||||||
|
for _, id := range stopped {
|
||||||
|
starts[id] = c.watchStartedAt[id]
|
||||||
|
delete(c.watchStartedAt, id)
|
||||||
|
delete(c.keyframeAt, id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
if tooMany {
|
if tooMany {
|
||||||
|
h.audit(c, true, auditScreenDenied, map[string]any{
|
||||||
|
"device_id": in.ID,
|
||||||
|
"reason": "too_many_watches",
|
||||||
|
})
|
||||||
c.queue(mustJSON(reply(false, "Too many devices on one connection")))
|
c.queue(mustJSON(reply(false, "Too many devices on one connection")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -414,6 +512,13 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
|
|||||||
// screen sessions are torn down outside the lock; deferredCloseScreen is
|
// screen sessions are torn down outside the lock; deferredCloseScreen is
|
||||||
// a no-op while another browser still watches the device.
|
// a no-op while another browser still watches the device.
|
||||||
for _, id := range stopped {
|
for _, id := range stopped {
|
||||||
|
if !starts[id].IsZero() {
|
||||||
|
h.audit(c, false, auditScreenStop, map[string]any{
|
||||||
|
"device_id": id,
|
||||||
|
"duration_ms": durationMs(starts[id]),
|
||||||
|
"reason": "replaced",
|
||||||
|
})
|
||||||
|
}
|
||||||
if h.countWatchers(id) == 0 {
|
if h.countWatchers(id) == 0 {
|
||||||
h.deferredCloseScreen(id)
|
h.deferredCloseScreen(id)
|
||||||
}
|
}
|
||||||
@@ -426,6 +531,15 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
|
|||||||
// next IDR (~15 s).
|
// next IDR (~15 s).
|
||||||
cache := h.devices.ScreenState(in.ID)
|
cache := h.devices.ScreenState(in.ID)
|
||||||
if cache.Active {
|
if cache.Active {
|
||||||
|
if !already {
|
||||||
|
h.audit(c, false, auditScreenStart, map[string]any{
|
||||||
|
"device_id": in.ID,
|
||||||
|
"mode": "cached",
|
||||||
|
"multi_watch": in.MultiWatch,
|
||||||
|
"width": cache.Width,
|
||||||
|
"height": cache.Height,
|
||||||
|
})
|
||||||
|
}
|
||||||
m := reply(true, "")
|
m := reply(true, "")
|
||||||
m["width"] = cache.Width
|
m["width"] = cache.Width
|
||||||
m["height"] = cache.Height
|
m["height"] = cache.Height
|
||||||
@@ -458,16 +572,30 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
|
|||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
delete(c.watchIDs, in.ID)
|
delete(c.watchIDs, in.ID)
|
||||||
delete(c.keyframeAt, in.ID)
|
delete(c.keyframeAt, in.ID)
|
||||||
|
delete(c.watchStartedAt, in.ID)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
msg := "Device offline"
|
msg := "Device offline"
|
||||||
|
reason := "device_offline"
|
||||||
if err != hub.ErrDeviceOffline {
|
if err != hub.ErrDeviceOffline {
|
||||||
msg = "Failed to start screen capture"
|
msg = "Failed to start screen capture"
|
||||||
|
reason = "start_failed"
|
||||||
h.log.Error("SendToDevice(%s): %v", in.ID, err)
|
h.log.Error("SendToDevice(%s): %v", in.ID, err)
|
||||||
}
|
}
|
||||||
|
h.audit(c, true, auditScreenDenied, map[string]any{
|
||||||
|
"device_id": in.ID,
|
||||||
|
"reason": reason,
|
||||||
|
})
|
||||||
c.queue(mustJSON(reply(false, msg)))
|
c.queue(mustJSON(reply(false, msg)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.log.Debug("[timing] COMMAND_SCREEN_SPY sent to device=%s (cold start)", in.ID)
|
h.log.Debug("[timing] COMMAND_SCREEN_SPY sent to device=%s (cold start)", in.ID)
|
||||||
|
if !already {
|
||||||
|
h.audit(c, false, auditScreenStart, map[string]any{
|
||||||
|
"device_id": in.ID,
|
||||||
|
"mode": "started",
|
||||||
|
"multi_watch": in.MultiWatch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
c.queue(mustJSON(reply(true, "")))
|
c.queue(mustJSON(reply(true, "")))
|
||||||
}
|
}
|
||||||
@@ -558,7 +686,10 @@ func (h *wsHub) handleRdpReset(c *wsClient, raw []byte) {
|
|||||||
}
|
}
|
||||||
deviceID := h.resolveWatchDevice(c, raw)
|
deviceID := h.resolveWatchDevice(c, raw)
|
||||||
if deviceID == "" {
|
if deviceID == "" {
|
||||||
h.log.Warn("rdp_reset: no device watched (addr=%s role=%s)", c.addr, c.role)
|
h.audit(c, true, auditRdpReset, map[string]any{
|
||||||
|
"ok": false,
|
||||||
|
"reason": "no_device",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// CMD_RESTORE_CONSOLE must go through the screen sub-conn — the client
|
// CMD_RESTORE_CONSOLE must go through the screen sub-conn — the client
|
||||||
@@ -566,24 +697,47 @@ func (h *wsHub) handleRdpReset(c *wsClient, raw []byte) {
|
|||||||
// the screen sub-conn (see client/ScreenManager.cpp:996). Sending on the
|
// the screen sub-conn (see client/ScreenManager.cpp:996). Sending on the
|
||||||
// main conn would silently no-op.
|
// main conn would silently no-op.
|
||||||
if err := h.devices.SendToScreen(deviceID, []byte{protocol.CmdRestoreConsole}); err != nil {
|
if err := h.devices.SendToScreen(deviceID, []byte{protocol.CmdRestoreConsole}); err != nil {
|
||||||
h.log.Warn("rdp_reset: device=%s: %v", deviceID, err)
|
h.audit(c, true, auditRdpReset, map[string]any{
|
||||||
|
"device_id": deviceID,
|
||||||
|
"ok": false,
|
||||||
|
"reason": "send_failed",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.log.Info("rdp_reset sent: device=%s", deviceID)
|
h.audit(c, false, auditRdpReset, map[string]any{
|
||||||
|
"device_id": deviceID,
|
||||||
|
"ok": true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *wsHub) handleGetDevices(c *wsClient, raw []byte) {
|
func (h *wsHub) handleGetDevices(c *wsClient, raw []byte) {
|
||||||
if !h.requireAuth(c, raw, "device_list") {
|
if !h.requireAuth(c, raw, "device_list") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
devices := h.devices.ListDevices()
|
|
||||||
c.queue(mustJSON(map[string]any{
|
c.queue(mustJSON(map[string]any{
|
||||||
"cmd": "device_list",
|
"cmd": "device_list",
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"devices": devices,
|
"devices": h.listDevicesWithOffline(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listDevicesWithOffline returns the online snapshot followed by historical
|
||||||
|
// devices that are not currently online. The online-ID set is derived from the
|
||||||
|
// same snapshot, so a device reconnecting between the two lookups cannot appear
|
||||||
|
// twice. With no history store wired up this is just the online list.
|
||||||
|
func (h *wsHub) listDevicesWithOffline() []hub.DeviceInfo {
|
||||||
|
devices := h.devices.ListDevices()
|
||||||
|
if h.history == nil {
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
online := make(map[string]struct{}, len(devices))
|
||||||
|
for _, d := range devices {
|
||||||
|
online[d.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
return append(devices, h.history.Offline(online)...)
|
||||||
|
}
|
||||||
|
|
||||||
// requireAuth validates the token embedded in raw against the authenticator's
|
// requireAuth validates the token embedded in raw against the authenticator's
|
||||||
// session store (not against c.token). Tokens live independently of WS
|
// session store (not against c.token). Tokens live independently of WS
|
||||||
// connections — the browser may reconnect after a visibility/network blip and
|
// connections — the browser may reconnect after a visibility/network blip and
|
||||||
@@ -596,21 +750,46 @@ func (h *wsHub) requireAuth(c *wsClient, raw []byte, replyCmd string) bool {
|
|||||||
}
|
}
|
||||||
_ = json.Unmarshal(raw, &in)
|
_ = json.Unmarshal(raw, &in)
|
||||||
if in.Token == "" {
|
if in.Token == "" {
|
||||||
|
h.auditBadToken(c, replyCmd, "missing_token")
|
||||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
sess, err := h.auth.ValidateToken(in.Token)
|
sess, err := h.auth.ValidateToken(in.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
h.auditBadToken(c, replyCmd, "invalid_token")
|
||||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
resumed := false
|
||||||
|
h.mu.Lock()
|
||||||
if c.token == "" {
|
if c.token == "" {
|
||||||
c.token = in.Token
|
c.token = in.Token
|
||||||
c.role = sess.Role
|
c.role = sess.Role
|
||||||
|
c.user = sess.Username
|
||||||
|
resumed = true
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
if resumed {
|
||||||
|
h.audit(c, false, auditSession, map[string]any{"method": "token"})
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auditBadToken counts rejected tokens and records only the first one per
|
||||||
|
// connection: an attacker can loop protected commands with garbage tokens,
|
||||||
|
// and one line plus the close-summary count is enough to see it.
|
||||||
|
func (h *wsHub) auditBadToken(c *wsClient, replyCmd, reason string) {
|
||||||
|
c.statInvalidToken++
|
||||||
|
if c.auditInvalidTokenLogged {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.auditInvalidTokenLogged = true
|
||||||
|
h.audit(c, true, auditBadToken, map[string]any{
|
||||||
|
"command": strings.TrimSuffix(replyCmd, "_result"),
|
||||||
|
"reason": reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// handleMouse forwards one mouse event from the browser to the device's
|
// handleMouse forwards one mouse event from the browser to the device's
|
||||||
// screen sub-connection as a COMMAND_SCREEN_CONTROL packet carrying a
|
// screen sub-connection as a COMMAND_SCREEN_CONTROL packet carrying a
|
||||||
// single MSG64. Mirrors the C++ CWebService::HandleMouse path
|
// single MSG64. Mirrors the C++ CWebService::HandleMouse path
|
||||||
@@ -778,19 +957,39 @@ func (h *wsHub) handleClipboardSet(c *wsClient, raw []byte) {
|
|||||||
}
|
}
|
||||||
deviceID := h.resolveWatchDevice(c, raw)
|
deviceID := h.resolveWatchDevice(c, raw)
|
||||||
if deviceID == "" {
|
if deviceID == "" {
|
||||||
|
h.audit(c, true, auditClipSet, map[string]any{
|
||||||
|
"ok": false,
|
||||||
|
"reason": "no_device",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(in.Text))
|
gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(in.Text))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Warn("clipboard_set: GBK encode failed: %v", err)
|
h.audit(c, true, auditClipSet, map[string]any{
|
||||||
|
"device_id": deviceID,
|
||||||
|
"ok": false,
|
||||||
|
"reason": "encode_failed",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
payload := append([]byte{protocol.CommandScreenSetClipboard}, gbk...)
|
payload := append([]byte{protocol.CommandScreenSetClipboard}, gbk...)
|
||||||
if err := h.devices.SendToScreen(deviceID, payload); err != nil {
|
if err := h.devices.SendToScreen(deviceID, payload); err != nil {
|
||||||
h.log.Warn("clipboard_set: device=%s: %v", deviceID, err)
|
h.audit(c, true, auditClipSet, map[string]any{
|
||||||
|
"device_id": deviceID,
|
||||||
|
"ok": false,
|
||||||
|
"reason": "send_failed",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.log.Info("clipboard_set sent: device=%s len=%d", deviceID, len(gbk))
|
// Record the size only — clipboard contents may be sensitive.
|
||||||
|
h.audit(c, false, auditClipSet, map[string]any{
|
||||||
|
"device_id": deviceID,
|
||||||
|
"ok": true,
|
||||||
|
"bytes": len(gbk),
|
||||||
|
"source_utf8": len(in.Text),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleClipboardGet asks the remote device for its clipboard text via
|
// handleClipboardGet asks the remote device for its clipboard text via
|
||||||
@@ -802,12 +1001,25 @@ func (h *wsHub) handleClipboardGet(c *wsClient, raw []byte) {
|
|||||||
}
|
}
|
||||||
deviceID := h.resolveWatchDevice(c, raw)
|
deviceID := h.resolveWatchDevice(c, raw)
|
||||||
if deviceID == "" {
|
if deviceID == "" {
|
||||||
|
h.audit(c, true, auditClipGet, map[string]any{
|
||||||
|
"ok": false,
|
||||||
|
"reason": "no_device",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.devices.SendToScreen(deviceID, []byte{protocol.CommandScreenGetClipboard}); err != nil {
|
if err := h.devices.SendToScreen(deviceID, []byte{protocol.CommandScreenGetClipboard}); err != nil {
|
||||||
h.log.Warn("clipboard_get: device=%s: %v", deviceID, err)
|
h.audit(c, true, auditClipGet, map[string]any{
|
||||||
|
"device_id": deviceID,
|
||||||
|
"ok": false,
|
||||||
|
"reason": "send_failed",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.audit(c, false, auditClipGet, map[string]any{
|
||||||
|
"device_id": deviceID,
|
||||||
|
"ok": true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCreateUser provisions a new web account. Admin-only. Mirrors the
|
// handleCreateUser provisions a new web account. Admin-only. Mirrors the
|
||||||
@@ -826,6 +1038,10 @@ func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) {
|
|||||||
AllowedGroups []string `json:"allowed_groups"`
|
AllowedGroups []string `json:"allowed_groups"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &in); err != nil {
|
if err := json.Unmarshal(raw, &in); err != nil {
|
||||||
|
h.audit(c, true, auditUserCreate, map[string]any{
|
||||||
|
"ok": false,
|
||||||
|
"reason": "invalid_json",
|
||||||
|
})
|
||||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -833,14 +1049,116 @@ func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) {
|
|||||||
in.Role = "viewer"
|
in.Role = "viewer"
|
||||||
}
|
}
|
||||||
if err := h.auth.CreateUser(in.Username, in.Password, in.Role, in.AllowedGroups); err != nil {
|
if err := h.auth.CreateUser(in.Username, in.Password, in.Role, in.AllowedGroups); err != nil {
|
||||||
|
h.audit(c, true, auditUserCreate, map[string]any{
|
||||||
|
"target": in.Username,
|
||||||
|
"target_role": in.Role,
|
||||||
|
"ok": false,
|
||||||
|
"reason": "create_failed",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.log.Info("user created by %s: name=%s role=%s groups=%d",
|
h.audit(c, false, auditUserCreate, map[string]any{
|
||||||
c.role, in.Username, in.Role, len(in.AllowedGroups))
|
"target": in.Username,
|
||||||
|
"target_role": in.Role,
|
||||||
|
"groups": len(in.AllowedGroups),
|
||||||
|
"ok": true,
|
||||||
|
})
|
||||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteDevice implements the device list's "Delete" action.
|
||||||
|
//
|
||||||
|
// The meaning depends on liveness, matching the MFC client's
|
||||||
|
// OnOnlineUninstall: an online agent is told to uninstall itself with the
|
||||||
|
// single-byte TOKEN_UNINSTALL command; an offline device has no agent to
|
||||||
|
// talk to. Either way the persisted history record is removed as well, so
|
||||||
|
// the deleted device does not linger in the offline list.
|
||||||
|
//
|
||||||
|
// Admin-only: this is destructive and irreversible.
|
||||||
|
func (h *wsHub) handleDeleteDevice(c *wsClient, raw []byte) {
|
||||||
|
const replyCmd = "delete_device_result"
|
||||||
|
if !h.requireAdmin(c, raw, replyCmd) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
|
||||||
|
h.audit(c, true, auditDeviceAdmin, map[string]any{
|
||||||
|
"ok": false,
|
||||||
|
"reason": "invalid_id",
|
||||||
|
})
|
||||||
|
c.queue(mustJSON(map[string]any{
|
||||||
|
"cmd": replyCmd, "ok": false, "msg": "Invalid device id",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.devices.IsOnline(in.ID) {
|
||||||
|
err := h.devices.SendToDevice(in.ID, []byte{protocol.CommandUninstall})
|
||||||
|
if err == nil {
|
||||||
|
// Drop the record too: after the agent uninstalls itself it will
|
||||||
|
// never log in again, so keeping it would leave a dead entry in
|
||||||
|
// the offline list. The disconnect event that follows finds no
|
||||||
|
// record and is a no-op.
|
||||||
|
if h.history != nil {
|
||||||
|
h.history.Remove(in.ID)
|
||||||
|
}
|
||||||
|
h.audit(c, false, auditDeviceAdmin, map[string]any{
|
||||||
|
"device_id": in.ID,
|
||||||
|
"mode": "uninstall",
|
||||||
|
"ok": true,
|
||||||
|
})
|
||||||
|
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
|
||||||
|
c.queue(mustJSON(map[string]any{
|
||||||
|
"cmd": replyCmd, "ok": true, "id": in.ID, "mode": "uninstall",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Lost the race with a disconnect between IsOnline and the send:
|
||||||
|
// fall through to removing the (now offline) history record.
|
||||||
|
if !errors.Is(err, hub.ErrDeviceOffline) {
|
||||||
|
h.audit(c, true, auditDeviceAdmin, map[string]any{
|
||||||
|
"device_id": in.ID,
|
||||||
|
"mode": "uninstall",
|
||||||
|
"ok": false,
|
||||||
|
"reason": "uninstall_failed",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
c.queue(mustJSON(map[string]any{
|
||||||
|
"cmd": replyCmd, "ok": false, "msg": err.Error(),
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.history == nil || !h.history.Remove(in.ID) {
|
||||||
|
h.audit(c, true, auditDeviceAdmin, map[string]any{
|
||||||
|
"device_id": in.ID,
|
||||||
|
"mode": "removed",
|
||||||
|
"ok": false,
|
||||||
|
"reason": "not_found",
|
||||||
|
})
|
||||||
|
c.queue(mustJSON(map[string]any{
|
||||||
|
"cmd": replyCmd, "ok": false, "msg": "Device not found",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit(c, false, auditDeviceAdmin, map[string]any{
|
||||||
|
"device_id": in.ID,
|
||||||
|
"mode": "removed",
|
||||||
|
"ok": true,
|
||||||
|
})
|
||||||
|
// Other browsers (and this one) refresh their lists; the reply below
|
||||||
|
// still gives the initiator a precise result/toast.
|
||||||
|
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
|
||||||
|
c.queue(mustJSON(map[string]any{
|
||||||
|
"cmd": replyCmd, "ok": true, "id": in.ID, "mode": "removed",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// handleDeleteUser removes an account and revokes any of its live sessions.
|
// handleDeleteUser removes an account and revokes any of its live sessions.
|
||||||
// Admin-only. The bootstrap "admin" account is rejected at the wsauth layer.
|
// Admin-only. The bootstrap "admin" account is rejected at the wsauth layer.
|
||||||
func (h *wsHub) handleDeleteUser(c *wsClient, raw []byte) {
|
func (h *wsHub) handleDeleteUser(c *wsClient, raw []byte) {
|
||||||
@@ -852,14 +1170,27 @@ func (h *wsHub) handleDeleteUser(c *wsClient, raw []byte) {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &in); err != nil {
|
if err := json.Unmarshal(raw, &in); err != nil {
|
||||||
|
h.audit(c, true, auditUserDelete, map[string]any{
|
||||||
|
"ok": false,
|
||||||
|
"reason": "invalid_json",
|
||||||
|
})
|
||||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.auth.DeleteUser(in.Username); err != nil {
|
if err := h.auth.DeleteUser(in.Username); err != nil {
|
||||||
|
h.audit(c, true, auditUserDelete, map[string]any{
|
||||||
|
"target": in.Username,
|
||||||
|
"ok": false,
|
||||||
|
"reason": "delete_failed",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.log.Info("user deleted by %s: name=%s", c.role, in.Username)
|
h.audit(c, false, auditUserDelete, map[string]any{
|
||||||
|
"target": in.Username,
|
||||||
|
"ok": true,
|
||||||
|
})
|
||||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,34 +1280,57 @@ func (h *wsHub) handleTermOpen(c *wsClient, raw []byte) {
|
|||||||
// screen path in handleConnect.
|
// screen path in handleConnect.
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
if c.termWatching != "" && c.termWatching != in.ID {
|
if c.termWatching != "" && c.termWatching != in.ID {
|
||||||
|
active := c.termWatching
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
h.audit(c, true, auditTermDenied, map[string]any{
|
||||||
|
"device_id": in.ID,
|
||||||
|
"reason": "other_terminal_open",
|
||||||
|
"active": active,
|
||||||
|
})
|
||||||
c.queue(mustJSON(map[string]any{
|
c.queue(mustJSON(map[string]any{
|
||||||
"cmd": replyCmd, "ok": false,
|
"cmd": replyCmd, "ok": false,
|
||||||
"msg": "Close current terminal before opening another",
|
"msg": "Close current terminal before opening another",
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
newSession := c.termWatching != in.ID
|
||||||
c.termWatching = in.ID
|
c.termWatching = in.ID
|
||||||
|
c.termCloseReason = ""
|
||||||
|
if newSession || c.termStartedAt.IsZero() {
|
||||||
|
c.termStartedAt = time.Now()
|
||||||
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
if err := h.devices.OpenTerminalSession(in.ID); err != nil {
|
if err := h.devices.OpenTerminalSession(in.ID); err != nil {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
c.termWatching = ""
|
c.termWatching = ""
|
||||||
|
c.termStartedAt = time.Time{}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
msg := "Device offline"
|
msg := "Device offline"
|
||||||
|
reason := "device_offline"
|
||||||
switch err {
|
switch err {
|
||||||
case hub.ErrTerminalBusy:
|
case hub.ErrTerminalBusy:
|
||||||
msg = "Terminal already open by another viewer"
|
msg = "Terminal already open by another viewer"
|
||||||
|
reason = "terminal_busy"
|
||||||
case hub.ErrDeviceOffline:
|
case hub.ErrDeviceOffline:
|
||||||
msg = "Device offline"
|
msg = "Device offline"
|
||||||
default:
|
default:
|
||||||
msg = "Failed to start terminal"
|
msg = "Failed to start terminal"
|
||||||
|
reason = "start_failed"
|
||||||
h.log.Error("OpenTerminalSession(%s): %v", in.ID, err)
|
h.log.Error("OpenTerminalSession(%s): %v", in.ID, err)
|
||||||
}
|
}
|
||||||
|
h.audit(c, true, auditTermDenied, map[string]any{
|
||||||
|
"device_id": in.ID,
|
||||||
|
"reason": reason,
|
||||||
|
})
|
||||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": msg}))
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": msg}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.log.Info("term_open: device=%s role=%s", in.ID, c.role)
|
if newSession {
|
||||||
|
h.audit(c, false, auditTermStart, map[string]any{
|
||||||
|
"device_id": in.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTermInput forwards xterm.js keystrokes to the device's shell
|
// handleTermInput forwards xterm.js keystrokes to the device's shell
|
||||||
@@ -997,7 +1351,10 @@ func (h *wsHub) handleTermInput(c *wsClient, raw []byte) {
|
|||||||
if in.ID == "" || in.Data == "" {
|
if in.ID == "" || in.Data == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if c.termWatching != in.ID {
|
h.mu.RLock()
|
||||||
|
watching := c.termWatching == in.ID
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if !watching {
|
||||||
return // someone else's session, or no session
|
return // someone else's session, or no session
|
||||||
}
|
}
|
||||||
_ = h.devices.SendToTerminal(in.ID, []byte(in.Data))
|
_ = h.devices.SendToTerminal(in.ID, []byte(in.Data))
|
||||||
@@ -1021,7 +1378,10 @@ func (h *wsHub) handleTermResize(c *wsClient, raw []byte) {
|
|||||||
if in.ID == "" || in.Cols <= 0 || in.Rows <= 0 {
|
if in.ID == "" || in.Cols <= 0 || in.Rows <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if c.termWatching != in.ID {
|
h.mu.RLock()
|
||||||
|
watching := c.termWatching == in.ID
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if !watching {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !h.devices.TerminalIsPTY(in.ID) {
|
if !h.devices.TerminalIsPTY(in.ID) {
|
||||||
@@ -1043,7 +1403,15 @@ func (h *wsHub) handleTermClose(c *wsClient, raw []byte) {
|
|||||||
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
|
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if c.termWatching != in.ID {
|
h.mu.Lock()
|
||||||
|
watching := c.termWatching == in.ID
|
||||||
|
if watching {
|
||||||
|
// Consumed by OnTerminalClosed for the remote.terminal.stop audit
|
||||||
|
// record (it fires synchronously inside CloseTerminalSession).
|
||||||
|
c.termCloseReason = "user"
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
if !watching {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.devices.CloseTerminalSession(in.ID)
|
h.devices.CloseTerminalSession(in.ID)
|
||||||
|
|||||||
+598
-35
@@ -374,6 +374,107 @@
|
|||||||
.user-msg.success { background: rgba(39, 174, 96, 0.2); color: #2ecc71; }
|
.user-msg.success { background: rgba(39, 174, 96, 0.2); color: #2ecc71; }
|
||||||
.user-msg.error { background: rgba(231, 76, 60, 0.2); color: #e74c3c; }
|
.user-msg.error { background: rgba(231, 76, 60, 0.2); color: #e74c3c; }
|
||||||
|
|
||||||
|
/* ===== Audit Log page ===== */
|
||||||
|
.icon-btn.audit { background: linear-gradient(135deg, #b9770e 0%, #e67e22 100%); display: none; }
|
||||||
|
.icon-btn.audit.visible { display: inline-flex; }
|
||||||
|
.icon-btn.audit:hover { box-shadow: 0 4px 12px rgba(230, 126, 34, 0.4); }
|
||||||
|
|
||||||
|
#audit-page { max-width: 1500px; margin: 0 auto; }
|
||||||
|
.audit-title-wrap { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.audit-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 10px 14px;
|
||||||
|
background: rgba(22, 33, 62, 0.8);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.audit-filters label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa4b2;
|
||||||
|
}
|
||||||
|
.audit-filters input[type="text"],
|
||||||
|
.audit-filters input[type="datetime-local"],
|
||||||
|
.audit-filters select {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(15, 52, 96, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.audit-filters input:focus, .audit-filters select:focus { border-color: #e67e22; }
|
||||||
|
.audit-filters label.audit-warn {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
.audit-filters label.audit-warn input { width: 15px; height: 15px; margin-right: 6px; }
|
||||||
|
.audit-actions { display: flex; gap: 10px; align-items: flex-end; }
|
||||||
|
.audit-actions button {
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.audit-search-btn { background: linear-gradient(135deg, #b9770e 0%, #e67e22 100%); color: #fff; }
|
||||||
|
.audit-search-btn:hover { transform: translateY(-1px); }
|
||||||
|
.audit-reset-btn { background: rgba(255,255,255,0.08); color: #ccc; }
|
||||||
|
.audit-reset-btn:hover { background: rgba(255,255,255,0.15); }
|
||||||
|
.audit-status { color: #888; font-size: 12px; margin-bottom: 10px; min-height: 16px; }
|
||||||
|
.audit-status.warn { color: #e67e22; }
|
||||||
|
.audit-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(22, 33, 62, 0.8);
|
||||||
|
}
|
||||||
|
.audit-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||||
|
.audit-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: #9aa4b2;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
background: rgba(15, 52, 96, 0.5);
|
||||||
|
}
|
||||||
|
.audit-table td {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
|
color: #d5dbe3;
|
||||||
|
vertical-align: top;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.audit-table tr:last-child td { border-bottom: none; }
|
||||||
|
.audit-table tr:hover td { background: rgba(230, 126, 34, 0.06); }
|
||||||
|
.audit-table td.audit-details {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #8f9aa8;
|
||||||
|
min-width: 220px;
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
.audit-level { padding: 2px 8px; border-radius: 10px; font-size: 11px; }
|
||||||
|
.audit-level.info { background: rgba(76, 175, 80, 0.15); color: #4caf50; }
|
||||||
|
.audit-level.warn { background: rgba(230, 126, 34, 0.18); color: #e67e22; }
|
||||||
|
.audit-empty { padding: 40px; text-align: center; color: #666; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.audit-filters { grid-template-columns: 1fr 1fr; }
|
||||||
|
.audit-actions { grid-column: 1 / -1; }
|
||||||
|
.audit-table td, .audit-table th { padding: 8px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Generic confirmation modal (compact yes/no dialog, e.g. logout) —— 复用 .modal-overlay
|
/* Generic confirmation modal (compact yes/no dialog, e.g. logout) —— 复用 .modal-overlay
|
||||||
做遮罩,自带一套紧凑布局 + 危险/取消双按钮风格。 */
|
做遮罩,自带一套紧凑布局 + 危险/取消双按钮风格。 */
|
||||||
.confirm-modal-content {
|
.confirm-modal-content {
|
||||||
@@ -455,11 +556,43 @@
|
|||||||
border-color: rgba(233, 69, 96, 0.3);
|
border-color: rgba(233, 69, 96, 0.3);
|
||||||
}
|
}
|
||||||
.device-card:hover::before { opacity: 1; }
|
.device-card:hover::before { opacity: 1; }
|
||||||
/* 终端图标按钮:右上角,独立于卡片本体点击事件(onclick stopPropagation) */
|
/* 离线设备:只展示,无点击/终端操作。虚线边框 + 灰显与在线卡片区分。 */
|
||||||
.device-card .card-term-btn {
|
.device-card.offline {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.device-card.offline:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
.device-card.offline::before { display: none; }
|
||||||
|
.device-card.offline h3 { color: #999; }
|
||||||
|
.device-card .last-seen {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.device-grid.list-view .device-card .last-seen {
|
||||||
|
margin-top: 0;
|
||||||
|
max-width: 200px;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
/* 卡片操作图标(终端 / 删除):右上角,独立于卡片本体点击事件
|
||||||
|
(onclick stopPropagation)。List 视图卡片是一行,改为垂直居中。 */
|
||||||
|
.device-card .card-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.device-card .card-icon-btn {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -471,11 +604,16 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
.device-card .card-icon-btn:hover { background: rgba(255,255,255,0.14); color: #fff; }
|
||||||
|
.device-card .card-icon-btn:active { transform: scale(0.92); }
|
||||||
.device-card .card-term-btn:hover { background: rgba(76, 175, 80, 0.25); color: #4caf50; }
|
.device-card .card-term-btn:hover { background: rgba(76, 175, 80, 0.25); color: #4caf50; }
|
||||||
.device-card .card-term-btn:active { transform: scale(0.92); }
|
.device-card .card-del-btn:hover { background: rgba(231, 76, 60, 0.25); color: #e74c3c; }
|
||||||
.device-card h3 { padding-right: 40px; } /* 给终端图标让位 */
|
.device-card h3 { padding-right: 86px; } /* 给右上角两个操作图标让位 */
|
||||||
|
.device-grid.list-view .device-card .card-actions {
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
/* ====== 终端页面 ====== */
|
/* ====== 终端页面 ====== */
|
||||||
#term-page {
|
#term-page {
|
||||||
@@ -605,9 +743,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
padding-right: 100px; /* 给垂直居中的操作图标列让位 */
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
.device-grid.list-view .device-card::before { display: none; }
|
.device-grid.list-view .device-card::before { display: none; }
|
||||||
|
.device-grid.list-view .device-card h3 { padding-right: 0; }
|
||||||
.device-grid.list-view .device-card h3 {
|
.device-grid.list-view .device-card h3 {
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -669,6 +809,20 @@
|
|||||||
}
|
}
|
||||||
.stats-bar .stat { color: #888; }
|
.stats-bar .stat { color: #888; }
|
||||||
.stats-bar .stat strong { color: #e94560; }
|
.stats-bar .stat strong { color: #e94560; }
|
||||||
|
/* 离线设备开关:服务端宣告 offlineDevices 能力后才显示(C++ 服务端没有该能力位)。 */
|
||||||
|
.stats-bar .stat.offline-toggle {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.stats-bar .stat.offline-toggle.visible { display: flex; }
|
||||||
|
.stats-bar .stat.offline-toggle input {
|
||||||
|
accent-color: #e94560;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
#screen-page {
|
#screen-page {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -1464,6 +1618,14 @@
|
|||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="icon-btn audit" id="audit-btn" onclick="openAuditPage()" title="Audit Log" aria-label="Audit Log">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="8" y1="13" x2="16" y2="13"/>
|
||||||
|
<line x1="8" y1="17" x2="16" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="icon-btn logout" onclick="logout()" title="Logout" aria-label="Logout">
|
<button class="icon-btn logout" onclick="logout()" title="Logout" aria-label="Logout">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
@@ -1476,6 +1638,11 @@
|
|||||||
<div id="stats-bar" class="stats-bar">
|
<div id="stats-bar" class="stats-bar">
|
||||||
<div class="stat">Total: <strong id="stat-total">0</strong></div>
|
<div class="stat">Total: <strong id="stat-total">0</strong></div>
|
||||||
<div class="stat">Showing: <strong id="stat-showing">0</strong></div>
|
<div class="stat">Showing: <strong id="stat-showing">0</strong></div>
|
||||||
|
<label class="stat offline-toggle" id="offline-toggle"
|
||||||
|
title="Show devices that have connected before but are offline now">
|
||||||
|
<input type="checkbox" id="show-offline" onchange="onOfflineToggle(this.checked)">
|
||||||
|
Offline
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="device-list" class="device-grid"></div>
|
<div id="device-list" class="device-grid"></div>
|
||||||
<div id="pagination" class="pagination"></div>
|
<div id="pagination" class="pagination"></div>
|
||||||
@@ -1612,6 +1779,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Audit Log Page (admin only) -->
|
||||||
|
<div id="audit-page" class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="audit-title-wrap">
|
||||||
|
<button class="back-btn" onclick="showPage('devices-page')" title="Back to devices">←</button>
|
||||||
|
<h2>Audit Log <span id="audit-count" class="device-count">0</span></h2>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="view-btn" onclick="queryAudit(0)" title="Reload newest records">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="audit-filters">
|
||||||
|
<label>From
|
||||||
|
<input type="datetime-local" id="audit-from" onkeydown="if(event.key==='Enter')queryAudit(0)">
|
||||||
|
</label>
|
||||||
|
<label>To
|
||||||
|
<input type="datetime-local" id="audit-to" onkeydown="if(event.key==='Enter')queryAudit(0)">
|
||||||
|
</label>
|
||||||
|
<label>Event (exact or prefix)
|
||||||
|
<input type="text" id="audit-event" list="audit-event-options" placeholder="e.g. web.* or web.login.failure"
|
||||||
|
onkeydown="if(event.key==='Enter')queryAudit(0)">
|
||||||
|
</label>
|
||||||
|
<datalist id="audit-event-options"></datalist>
|
||||||
|
<label>Actor
|
||||||
|
<input type="text" id="audit-actor" placeholder="username" onkeydown="if(event.key==='Enter')queryAudit(0)">
|
||||||
|
</label>
|
||||||
|
<label>IP
|
||||||
|
<input type="text" id="audit-ip" placeholder="203.0.113.1" onkeydown="if(event.key==='Enter')queryAudit(0)">
|
||||||
|
</label>
|
||||||
|
<label>Device ID
|
||||||
|
<input type="text" id="audit-device" placeholder="device id" onkeydown="if(event.key==='Enter')queryAudit(0)">
|
||||||
|
</label>
|
||||||
|
<label>Keyword
|
||||||
|
<input type="text" id="audit-keyword" placeholder="reason, target, ..." onkeydown="if(event.key==='Enter')queryAudit(0)">
|
||||||
|
</label>
|
||||||
|
<label>Limit
|
||||||
|
<select id="audit-limit">
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100" selected>100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
<option value="500">500</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="audit-warn">
|
||||||
|
<input type="checkbox" id="audit-warn-only"> Warnings only
|
||||||
|
</label>
|
||||||
|
<div class="audit-actions">
|
||||||
|
<button class="audit-search-btn" onclick="queryAudit(0)">Search</button>
|
||||||
|
<button class="audit-reset-btn" onclick="resetAuditFilters()">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="audit-status" class="audit-status"></div>
|
||||||
|
<div class="audit-table-wrap">
|
||||||
|
<table class="audit-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th><th>Level</th><th>Event</th><th>Actor</th>
|
||||||
|
<th>Role</th><th>IP</th><th>Device</th><th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="audit-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="audit-pagination" class="pagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- User Management Modal -->
|
<!-- User Management Modal -->
|
||||||
<div class="modal-overlay" id="users-modal">
|
<div class="modal-overlay" id="users-modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -1660,6 +1893,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Device Confirmation Modal(离线=删除历史记录;在线=下发卸载主机) -->
|
||||||
|
<div class="modal-overlay" id="delete-device-modal" onclick="if(event.target===this)cancelDeleteDevice()">
|
||||||
|
<div class="confirm-modal-content">
|
||||||
|
<div class="confirm-modal-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||||
|
<path d="M10 11v6"/><path d="M14 11v6"/>
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 id="delete-device-title">Delete Device</h3>
|
||||||
|
<p id="delete-device-msg"></p>
|
||||||
|
<div class="confirm-modal-actions">
|
||||||
|
<button class="cancel-btn" onclick="cancelDeleteDevice()">Cancel</button>
|
||||||
|
<button class="danger-btn" id="delete-device-confirm" onclick="confirmDeleteDevice()">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="__BASE_PATH__/static/xterm.js"></script>
|
<script src="__BASE_PATH__/static/xterm.js"></script>
|
||||||
<script src="__BASE_PATH__/static/xterm-fit.js"></script>
|
<script src="__BASE_PATH__/static/xterm-fit.js"></script>
|
||||||
|
|
||||||
@@ -1703,7 +1956,15 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 服务端能力协商:旧 C++ / Go 服务端没有 caps,前端隐藏 Wall。
|
// 服务端能力协商:旧 C++ / Go 服务端没有 caps,前端隐藏 Wall。
|
||||||
let serverCaps = { multiWatch: false, keyframeRequest: false };
|
let serverCaps = { multiWatch: false, keyframeRequest: false, offlineDevices: false, auditLog: false };
|
||||||
|
|
||||||
|
// 离线设备开关:localStorage 只保存用户偏好,是否可用由服务端能力位决定。
|
||||||
|
// 关闭时只影响 Grid/List;Wall 侧边栏始终列出离线设备(灰显、不可操作)。
|
||||||
|
const SHOW_OFFLINE_STORAGE_KEY = 'showOfflineDevices';
|
||||||
|
let showOffline = false;
|
||||||
|
try {
|
||||||
|
showOffline = localStorage.getItem(SHOW_OFFLINE_STORAGE_KEY) === '1';
|
||||||
|
} catch (e) { /* 隐私模式下 localStorage 可能不可用 */ }
|
||||||
let wall = {
|
let wall = {
|
||||||
active: false,
|
active: false,
|
||||||
layout: isTouchDevice ? 4 : 9,
|
layout: isTouchDevice ? 4 : 9,
|
||||||
@@ -2120,9 +2381,13 @@
|
|||||||
// 能力协商:旧 C++ / Go 服务端没有 caps → 隐藏 Wall,保持单设备行为。
|
// 能力协商:旧 C++ / Go 服务端没有 caps → 隐藏 Wall,保持单设备行为。
|
||||||
serverCaps = {
|
serverCaps = {
|
||||||
multiWatch: !!(msg.caps && msg.caps.multiWatch),
|
multiWatch: !!(msg.caps && msg.caps.multiWatch),
|
||||||
keyframeRequest: !!(msg.caps && msg.caps.keyframeRequest)
|
keyframeRequest: !!(msg.caps && msg.caps.keyframeRequest),
|
||||||
|
offlineDevices: !!(msg.caps && msg.caps.offlineDevices),
|
||||||
|
auditLog: !!(msg.caps && msg.caps.auditLog)
|
||||||
};
|
};
|
||||||
updateWallAvailability();
|
updateWallAvailability();
|
||||||
|
applyOfflineCapability();
|
||||||
|
updateAdminButtons();
|
||||||
console.log('Received challenge nonce; caps=', serverCaps);
|
console.log('Received challenge nonce; caps=', serverCaps);
|
||||||
break;
|
break;
|
||||||
case 'salt':
|
case 'salt':
|
||||||
@@ -2140,24 +2405,25 @@
|
|||||||
localStorage.setItem('token', token);
|
localStorage.setItem('token', token);
|
||||||
localStorage.setItem('role', currentUserRole);
|
localStorage.setItem('role', currentUserRole);
|
||||||
document.getElementById('login-error').textContent = '';
|
document.getElementById('login-error').textContent = '';
|
||||||
// Show Users button for admin only
|
// Show Users / Audit Log buttons for admin only
|
||||||
const usersBtn = document.getElementById('users-btn');
|
updateAdminButtons();
|
||||||
if (currentUserRole === 'admin') {
|
|
||||||
usersBtn.classList.add('visible');
|
|
||||||
} else {
|
|
||||||
usersBtn.classList.remove('visible');
|
|
||||||
}
|
|
||||||
showPage('devices-page');
|
showPage('devices-page');
|
||||||
getDevices();
|
getDevices();
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('login-error').textContent = msg.msg || 'Login failed';
|
document.getElementById('login-error').textContent = msg.msg || 'Login failed';
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'audit_result':
|
||||||
|
handleAuditResult(msg);
|
||||||
|
break;
|
||||||
case 'device_list':
|
case 'device_list':
|
||||||
if (msg.ok === false) {
|
if (msg.ok === false) {
|
||||||
// Token invalid (e.g., server restarted), go back to login
|
// Token invalid (e.g., server restarted), go back to login
|
||||||
token = null;
|
token = null;
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('role');
|
||||||
|
currentUserRole = 'viewer';
|
||||||
|
updateAdminButtons();
|
||||||
showPage('login-page');
|
showPage('login-page');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2370,6 +2636,15 @@
|
|||||||
showUserMsg(msg.msg || 'Failed to delete user', true);
|
showUserMsg(msg.msg || 'Failed to delete user', true);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'delete_device_result':
|
||||||
|
if (msg.ok) {
|
||||||
|
// mode=uninstall:命令已下发,设备稍后自行断开;
|
||||||
|
// mode=removed:历史记录已删除,刷新后卡片消失。
|
||||||
|
getDevices();
|
||||||
|
} else {
|
||||||
|
alert(msg.msg || 'Failed to delete device');
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'list_users_result':
|
case 'list_users_result':
|
||||||
if (msg.ok) {
|
if (msg.ok) {
|
||||||
renderUsersList(msg.users);
|
renderUsersList(msg.users);
|
||||||
@@ -3091,6 +3366,7 @@
|
|||||||
|
|
||||||
function wallTooltipHtml(d) {
|
function wallTooltipHtml(d) {
|
||||||
// 与 Grid 模式 device-card 的字段/顺序保持一致。
|
// 与 Grid 模式 device-card 的字段/顺序保持一致。
|
||||||
|
const offline = d.online === false;
|
||||||
const screenInfo = escapeHtml(d.screen || '-');
|
const screenInfo = escapeHtml(d.screen || '-');
|
||||||
const rtt = d.rtt || '-';
|
const rtt = d.rtt || '-';
|
||||||
const activeWin = d.activeWindow || '';
|
const activeWin = d.activeWindow || '';
|
||||||
@@ -3101,11 +3377,14 @@
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="info"><span class="info-label">OS:</span> ' + escapeHtml(d.os || '-') + '</div>' +
|
'<div class="info"><span class="info-label">OS:</span> ' + escapeHtml(d.os || '-') + '</div>' +
|
||||||
'<div class="meta-row">' +
|
'<div class="meta-row">' +
|
||||||
'<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>' +
|
(offline
|
||||||
|
? '<span class="meta-item offline-badge">Offline</span>'
|
||||||
|
: '<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>') +
|
||||||
'<span class="meta-item">Ver: ' + escapeHtml(d.version || '-') + '</span>' +
|
'<span class="meta-item">Ver: ' + escapeHtml(d.version || '-') + '</span>' +
|
||||||
'<span class="meta-item">' + screenInfo + '</span>' +
|
'<span class="meta-item">' + screenInfo + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
(activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '">' +
|
(offline ? '<div class="last-seen">Last seen: ' + escapeHtml(formatLastSeen(d.last_seen)) + '</div>' : '') +
|
||||||
|
(!offline && activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '">' +
|
||||||
escapeHtml(activeWin) + '</div>' : '');
|
escapeHtml(activeWin) + '</div>' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3749,8 +4028,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scopeDevices() {
|
||||||
|
// Total / Showing 都基于当前范围:未勾选 Offline 时只统计在线设备。
|
||||||
|
if (showOffline) return devices;
|
||||||
|
return devices.filter(d => d.online !== false);
|
||||||
|
}
|
||||||
|
|
||||||
function getFilteredDevices() {
|
function getFilteredDevices() {
|
||||||
let filtered = devices;
|
let filtered = scopeDevices();
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
filtered = filtered.filter(d =>
|
filtered = filtered.filter(d =>
|
||||||
@@ -3766,6 +4051,37 @@
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyOfflineCapability() {
|
||||||
|
const label = document.getElementById('offline-toggle');
|
||||||
|
const cb = document.getElementById('show-offline');
|
||||||
|
if (!label || !cb) return;
|
||||||
|
const supported = !!serverCaps.offlineDevices;
|
||||||
|
label.classList.toggle('visible', supported);
|
||||||
|
if (!supported) showOffline = false;
|
||||||
|
cb.checked = showOffline;
|
||||||
|
renderDeviceList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOfflineToggle(checked) {
|
||||||
|
showOffline = !!checked;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SHOW_OFFLINE_STORAGE_KEY, showOffline ? '1' : '0');
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
currentPage = 1;
|
||||||
|
renderDeviceList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastSeen(ts) {
|
||||||
|
if (!ts) return 'unknown';
|
||||||
|
const secs = Math.max(0, Math.floor(Date.now() / 1000 - ts));
|
||||||
|
if (secs < 60) return 'just now';
|
||||||
|
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
|
||||||
|
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
|
||||||
|
const days = Math.floor(secs / 86400);
|
||||||
|
if (days < 30) return days + 'd ago';
|
||||||
|
return new Date(ts * 1000).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
function onSearchChange() {
|
function onSearchChange() {
|
||||||
searchQuery = document.getElementById('search-input').value;
|
searchQuery = document.getElementById('search-input').value;
|
||||||
document.getElementById('search-clear').style.display = searchQuery ? 'block' : 'none';
|
document.getElementById('search-clear').style.display = searchQuery ? 'block' : 'none';
|
||||||
@@ -3865,9 +4181,11 @@
|
|||||||
const start = (currentPage - 1) * ps;
|
const start = (currentPage - 1) * ps;
|
||||||
const pageDevices = filtered.slice(start, start + ps);
|
const pageDevices = filtered.slice(start, start + ps);
|
||||||
|
|
||||||
// Update stats
|
// Update stats. Total is the current scope (online-only unless the
|
||||||
document.getElementById('device-count').textContent = devices.length;
|
// Offline toggle is on); Showing is after the search filter.
|
||||||
document.getElementById('stat-total').textContent = devices.length;
|
const scope = scopeDevices();
|
||||||
|
document.getElementById('device-count').textContent = scope.length;
|
||||||
|
document.getElementById('stat-total').textContent = scope.length;
|
||||||
document.getElementById('stat-showing').textContent = filtered.length;
|
document.getElementById('stat-showing').textContent = filtered.length;
|
||||||
|
|
||||||
// Render devices
|
// Render devices
|
||||||
@@ -3876,18 +4194,42 @@
|
|||||||
list.innerHTML = '<div class="no-devices"><h3>No devices found</h3><p>Try adjusting your search or filter</p></div>';
|
list.innerHTML = '<div class="no-devices"><h3>No devices found</h3><p>Try adjusting your search or filter</p></div>';
|
||||||
} else {
|
} else {
|
||||||
list.innerHTML = pageDevices.map(d => {
|
list.innerHTML = pageDevices.map(d => {
|
||||||
|
const offline = d.online === false;
|
||||||
const screenInfo = d.screen || '-'; // e.g. "2:3840x1080"
|
const screenInfo = d.screen || '-'; // e.g. "2:3840x1080"
|
||||||
const loc = d.location || '-';
|
const loc = d.location || '-';
|
||||||
const rtt = d.rtt || '-';
|
const rtt = d.rtt || '-';
|
||||||
const ver = d.version || '-';
|
const ver = d.version || '-';
|
||||||
const activeWin = d.activeWindow || '';
|
const activeWin = d.activeWindow || '';
|
||||||
return '<div class="device-card" onclick="connectDevice(\'' + d.id + '\')">' +
|
// 操作图标:在线卡片有终端;删除对所有卡片开放(离线=删记录,
|
||||||
'<button class="card-term-btn" title="Open Terminal" aria-label="Terminal" ' +
|
// 在线=卸载主机),仅管理员可用。Wall 模式不提供删除。
|
||||||
'onclick="event.stopPropagation();openTerminal(\'' + d.id + '\')">' +
|
const actions = [];
|
||||||
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
if (!offline) {
|
||||||
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
|
actions.push(
|
||||||
'</svg>' +
|
'<button class="card-icon-btn card-term-btn" title="Open Terminal" aria-label="Terminal" ' +
|
||||||
'</button>' +
|
'onclick="event.stopPropagation();openTerminal(\'' + d.id + '\')">' +
|
||||||
|
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
||||||
|
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
|
||||||
|
'</svg>' +
|
||||||
|
'</button>');
|
||||||
|
}
|
||||||
|
if (canDeleteDevices()) {
|
||||||
|
actions.push(
|
||||||
|
'<button class="card-icon-btn card-del-btn" title="' +
|
||||||
|
(offline ? 'Delete record' : 'Uninstall host') + '" aria-label="Delete" ' +
|
||||||
|
'onclick="event.stopPropagation();askDeleteDevice(\'' + d.id + '\')">' +
|
||||||
|
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
||||||
|
'<polyline points="3 6 5 6 21 6"/>' +
|
||||||
|
'<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>' +
|
||||||
|
'<path d="M10 11v6"/><path d="M14 11v6"/>' +
|
||||||
|
'<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>' +
|
||||||
|
'</svg>' +
|
||||||
|
'</button>');
|
||||||
|
}
|
||||||
|
const actionsHtml = actions.length
|
||||||
|
? '<div class="card-actions">' + actions.join('') + '</div>' : '';
|
||||||
|
return '<div class="device-card' + (offline ? ' offline' : '') + '"' +
|
||||||
|
(offline ? '' : ' onclick="connectDevice(\'' + d.id + '\')"') + '>' +
|
||||||
|
actionsHtml +
|
||||||
'<h3>' + escapeHtml(displayName(d)) + '</h3>' +
|
'<h3>' + escapeHtml(displayName(d)) + '</h3>' +
|
||||||
'<div class="info-row">' +
|
'<div class="info-row">' +
|
||||||
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
|
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
|
||||||
@@ -3895,11 +4237,14 @@
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="info"><span class="info-label">OS:</span> ' + escapeHtml(d.os || '-') + '</div>' +
|
'<div class="info"><span class="info-label">OS:</span> ' + escapeHtml(d.os || '-') + '</div>' +
|
||||||
'<div class="meta-row">' +
|
'<div class="meta-row">' +
|
||||||
'<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>' +
|
(offline
|
||||||
|
? '<span class="meta-item offline-badge">Offline</span>'
|
||||||
|
: '<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>') +
|
||||||
'<span class="meta-item">Ver: ' + escapeHtml(ver) + '</span>' +
|
'<span class="meta-item">Ver: ' + escapeHtml(ver) + '</span>' +
|
||||||
'<span class="meta-item">' + screenInfo + '</span>' +
|
'<span class="meta-item">' + screenInfo + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
(activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '" title="' + escapeHtml(activeWin) + '">' + escapeHtml(activeWin) + '</div>' : '') +
|
(offline ? '<div class="last-seen">Last seen: ' + escapeHtml(formatLastSeen(d.last_seen)) + '</div>' : '') +
|
||||||
|
(!offline && activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '" title="' + escapeHtml(activeWin) + '">' + escapeHtml(activeWin) + '</div>' : '') +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -4035,11 +4380,55 @@
|
|||||||
challengeNonce = '';
|
challengeNonce = '';
|
||||||
connectWebSocket(); // Reconnect immediately
|
connectWebSocket(); // Reconnect immediately
|
||||||
token = null;
|
token = null;
|
||||||
|
currentUserRole = 'viewer';
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('role');
|
||||||
devices = [];
|
devices = [];
|
||||||
showPage('login-page');
|
showPage('login-page');
|
||||||
// Hide users button
|
// Hide admin-only buttons
|
||||||
document.getElementById('users-btn').classList.remove('visible');
|
updateAdminButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 删除设备 =====
|
||||||
|
// 两种情况都删除服务端 devices.json 中的历史记录;在线设备额外按 MFC
|
||||||
|
// 的做法发送单字节 TOKEN_UNINSTALL(卸载主机)。都先弹二次确认。
|
||||||
|
// 仅管理员可见/可用。
|
||||||
|
let pendingDeleteDeviceId = null;
|
||||||
|
|
||||||
|
function canDeleteDevices() {
|
||||||
|
return currentUserRole === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function askDeleteDevice(id) {
|
||||||
|
if (!canDeleteDevices()) return;
|
||||||
|
const d = devices.find(x => String(x.id) === String(id));
|
||||||
|
if (!d) { alert('Device not found'); return; }
|
||||||
|
const offline = d.online === false;
|
||||||
|
const name = displayName(d);
|
||||||
|
pendingDeleteDeviceId = String(id);
|
||||||
|
document.getElementById('delete-device-title').textContent =
|
||||||
|
offline ? 'Delete Device Record' : 'Uninstall Host';
|
||||||
|
document.getElementById('delete-device-msg').innerHTML = offline
|
||||||
|
? 'Remove <strong>' + escapeHtml(name) + '</strong> from the server\'s device history?' +
|
||||||
|
'<br>This cannot be undone.'
|
||||||
|
: 'Send the uninstall command to <strong>' + escapeHtml(name) + '</strong>?' +
|
||||||
|
'<br>The remote agent will uninstall itself, disconnect, and its record will be deleted.';
|
||||||
|
document.getElementById('delete-device-confirm').textContent =
|
||||||
|
offline ? 'Delete' : 'Uninstall';
|
||||||
|
document.getElementById('delete-device-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDeleteDevice() {
|
||||||
|
pendingDeleteDeviceId = null;
|
||||||
|
document.getElementById('delete-device-modal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteDevice() {
|
||||||
|
const id = pendingDeleteDeviceId;
|
||||||
|
document.getElementById('delete-device-modal').classList.remove('active');
|
||||||
|
pendingDeleteDeviceId = null;
|
||||||
|
if (!id || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
ws.send(JSON.stringify({ cmd: 'delete_device', token, id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// User Management Functions
|
// User Management Functions
|
||||||
@@ -4122,6 +4511,181 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Audit Log (admin only) =====
|
||||||
|
// Server-side handler: query_audit -> audit_result (see web/audit_query.go).
|
||||||
|
// Records are newest-first and already redacted at write time.
|
||||||
|
const AUDIT_EVENTS = [
|
||||||
|
'web.ws.connect', 'web.ws.reject', 'web.ws.close',
|
||||||
|
'web.login.success', 'web.login.failure', 'web.login.throttled',
|
||||||
|
'web.session.resume', 'web.auth.invalid_token', 'web.authz.denied',
|
||||||
|
'web.command.unknown', 'web.salt.unknown_user',
|
||||||
|
'remote.screen.start', 'remote.screen.stop', 'remote.screen.denied',
|
||||||
|
'remote.terminal.start', 'remote.terminal.stop', 'remote.terminal.denied',
|
||||||
|
'remote.clipboard.set', 'remote.clipboard.get', 'remote.rdp_reset',
|
||||||
|
'admin.user.create', 'admin.user.delete', 'admin.device.delete', 'admin.audit.query',
|
||||||
|
'device.online', 'device.offline',
|
||||||
|
'device.auth.success', 'device.auth.failure', 'device.auth.throttled',
|
||||||
|
'device.auth.heartbeat.success',
|
||||||
|
'device.connauth.rejected', 'device.subconn.foreign_ip'
|
||||||
|
];
|
||||||
|
let auditState = { offset: 0, limit: 100, total: 0, loaded: false };
|
||||||
|
|
||||||
|
// 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 (auditLog); servers that
|
||||||
|
// do not implement query_audit simply never advertise it.
|
||||||
|
function updateAdminButtons() {
|
||||||
|
const admin = currentUserRole === 'admin';
|
||||||
|
const usersBtn = document.getElementById('users-btn');
|
||||||
|
if (usersBtn) usersBtn.classList.toggle('visible', admin);
|
||||||
|
const auditBtn = document.getElementById('audit-btn');
|
||||||
|
if (auditBtn) auditBtn.classList.toggle('visible', admin && !!serverCaps.auditLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAuditPage() {
|
||||||
|
if (currentUserRole !== 'admin' || !serverCaps.auditLog) return;
|
||||||
|
fillAuditEventOptions();
|
||||||
|
showPage('audit-page');
|
||||||
|
queryAudit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillAuditEventOptions() {
|
||||||
|
const dl = document.getElementById('audit-event-options');
|
||||||
|
if (!dl || dl.children.length > 0) return;
|
||||||
|
dl.innerHTML = AUDIT_EVENTS.map(e => '<option value="' + e + '"></option>').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// datetime-local values are in the browser's local zone; the server
|
||||||
|
// compares against RFC3339 audit timestamps, so convert explicitly.
|
||||||
|
function isoFromLocalInput(id) {
|
||||||
|
const v = document.getElementById(id).value;
|
||||||
|
if (!v) return '';
|
||||||
|
const d = new Date(v);
|
||||||
|
return isNaN(d.getTime()) ? '' : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryAudit(offset) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN || !token) return;
|
||||||
|
if (currentUserRole !== 'admin') return;
|
||||||
|
const payload = {
|
||||||
|
cmd: 'query_audit',
|
||||||
|
token,
|
||||||
|
limit: parseInt(document.getElementById('audit-limit').value, 10) || 100,
|
||||||
|
offset: Math.max(0, offset | 0),
|
||||||
|
event: document.getElementById('audit-event').value.trim(),
|
||||||
|
actor: document.getElementById('audit-actor').value.trim(),
|
||||||
|
ip: document.getElementById('audit-ip').value.trim(),
|
||||||
|
device_id: document.getElementById('audit-device').value.trim(),
|
||||||
|
keyword: document.getElementById('audit-keyword').value.trim(),
|
||||||
|
warn_only: document.getElementById('audit-warn-only').checked
|
||||||
|
};
|
||||||
|
const from = isoFromLocalInput('audit-from');
|
||||||
|
if (from) payload.from = from;
|
||||||
|
const to = isoFromLocalInput('audit-to');
|
||||||
|
if (to) payload.to = to;
|
||||||
|
setAuditStatus('Querying...', false);
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAuditFilters() {
|
||||||
|
['audit-from', 'audit-to', 'audit-event', 'audit-actor', 'audit-ip', 'audit-device', 'audit-keyword']
|
||||||
|
.forEach(id => { document.getElementById(id).value = ''; });
|
||||||
|
document.getElementById('audit-warn-only').checked = false;
|
||||||
|
queryAudit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAuditStatus(text, warn) {
|
||||||
|
const el = document.getElementById('audit-status');
|
||||||
|
el.textContent = text || '';
|
||||||
|
el.className = 'audit-status' + (warn ? ' warn' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuditResult(msg) {
|
||||||
|
if (!msg.ok) {
|
||||||
|
setAuditStatus(msg.msg || 'Query failed', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auditState.total = msg.total || 0;
|
||||||
|
auditState.offset = msg.offset || 0;
|
||||||
|
auditState.limit = msg.limit || auditState.limit;
|
||||||
|
auditState.loaded = true;
|
||||||
|
|
||||||
|
renderAuditRows(msg.events || []);
|
||||||
|
renderAuditPagination();
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (!msg.file_exists) {
|
||||||
|
parts.push('No audit file yet — no security events have been recorded.');
|
||||||
|
} else {
|
||||||
|
parts.push(auditState.total + ' matching record(s)');
|
||||||
|
const shown = (msg.events || []).length;
|
||||||
|
if (shown !== auditState.total) parts.push('showing ' + shown);
|
||||||
|
if (msg.file) parts.push('file: ' + msg.file);
|
||||||
|
}
|
||||||
|
if (msg.truncated) {
|
||||||
|
parts.push('only the newest ' +
|
||||||
|
Math.round((msg.scanned_bytes || 0) / 1048576) + ' MB of the file was scanned');
|
||||||
|
}
|
||||||
|
setAuditStatus(parts.join(' · '), !!msg.truncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAuditTime(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = new Date(s);
|
||||||
|
return isNaN(d.getTime()) ? escapeHtml(s) : d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUDIT_STD_FIELDS = new Set(['time', 'level', 'event', 'actor', 'role', 'ip', 'device_id', 'audit', 'message', 'module']);
|
||||||
|
function formatAuditDetails(rec) {
|
||||||
|
return Object.keys(rec)
|
||||||
|
.filter(k => !AUDIT_STD_FIELDS.has(k))
|
||||||
|
.sort()
|
||||||
|
.map(k => {
|
||||||
|
let v = rec[k];
|
||||||
|
if (v !== null && typeof v === 'object') v = JSON.stringify(v);
|
||||||
|
return escapeHtml(k) + '=' + escapeHtml(String(v));
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuditRows(events) {
|
||||||
|
document.getElementById('audit-count').textContent = auditState.total;
|
||||||
|
const tbody = document.getElementById('audit-rows');
|
||||||
|
if (!events.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="8" class="audit-empty">No audit records match the filters</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = events.map(rec => {
|
||||||
|
const level = rec.level === 'warn' ? 'warn' : 'info';
|
||||||
|
return '<tr>' +
|
||||||
|
'<td title="' + escapeHtml(rec.time || '') + '">' + formatAuditTime(rec.time) + '</td>' +
|
||||||
|
'<td><span class="audit-level ' + level + '">' + level + '</span></td>' +
|
||||||
|
'<td>' + escapeHtml(rec.event || '') + '</td>' +
|
||||||
|
'<td>' + escapeHtml(rec.actor || '') + '</td>' +
|
||||||
|
'<td>' + escapeHtml(rec.role || '') + '</td>' +
|
||||||
|
'<td>' + escapeHtml(rec.ip || '') + '</td>' +
|
||||||
|
'<td>' + escapeHtml(rec.device_id || '') + '</td>' +
|
||||||
|
'<td class="audit-details">' + formatAuditDetails(rec) + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuditPagination() {
|
||||||
|
const container = document.getElementById('audit-pagination');
|
||||||
|
if (auditState.total <= auditState.limit) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = Math.floor(auditState.offset / auditState.limit) + 1;
|
||||||
|
const pages = Math.ceil(auditState.total / auditState.limit);
|
||||||
|
const prev = Math.max(0, auditState.offset - auditState.limit);
|
||||||
|
const next = auditState.offset + auditState.limit;
|
||||||
|
container.innerHTML =
|
||||||
|
'<button onclick="queryAudit(' + prev + ')" ' + (page <= 1 ? 'disabled' : '') + '>Prev</button>' +
|
||||||
|
'<span class="page-info">Page ' + page + ' of ' + pages + '</span>' +
|
||||||
|
'<button onclick="queryAudit(' + next + ')" ' + (page >= pages ? 'disabled' : '') + '>Next</button>';
|
||||||
|
}
|
||||||
|
|
||||||
function renderUsersList(users) {
|
function renderUsersList(users) {
|
||||||
const container = document.getElementById('users-list');
|
const container = document.getElementById('users-list');
|
||||||
if (!users || users.length === 0) {
|
if (!users || users.length === 0) {
|
||||||
@@ -6191,10 +6755,9 @@
|
|||||||
// (not logged in, or SSO disabled) fall back to the restored
|
// (not logged in, or SSO disabled) fall back to the restored
|
||||||
// session or the built-in login form.
|
// session or the built-in login form.
|
||||||
trySSO().then(() => {
|
trySSO().then(() => {
|
||||||
// Show Users button for admin only (final role known after SSO).
|
// Show admin-only buttons once the final role is known (SSO or local login).
|
||||||
if (token && currentUserRole === 'admin') {
|
if (!token) currentUserRole = 'viewer';
|
||||||
document.getElementById('users-btn').classList.add('visible');
|
updateAdminButtons();
|
||||||
}
|
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user