7 Commits
Author SHA1 Message Date
yuanyuanxianganddeepseek-flash f76cd79bb0 Fix: Renew expired licenses from today and report the actual expiry
Preset renewal stored its target date as the old expiry plus the entered
days, while the authorization actually issued to the client is always
today plus a day count. For a license that had already expired, the
preset date was therefore in the past: SendPendingRenewal clamped the
day count to one, the client received a one-day license starting today,
but the renewal tip and the logs still printed the stale preset date.
The result was a message such as "renewed until 2026-05-30" while the
license really ran until tomorrow.

Presetting a renewal now bases the new date on today when the license
has already expired, and on the original expiry date otherwise, which
matches the one-day-per-day extension admins expect for live licenses.
The renewal dialog and the success prompt state which base was used, so
the computed date is never ambiguous. Renewal tips and both logging
paths (heartbeat-driven SendPendingRenewal and client-requested
GenerateRenewalInfo) now print the date that was actually issued, which
also corrects legacy preset records whose stored date has already
passed.

Co-Authored-By: deepseek-flash
2026-10-19 15:15:49 +02:00
yuanyuanxianganddeepseek-flash 39c8daa9b0 Fix: Keep the audit log open across WebSocket reconnects
Hiding the browser tab while the audit log was open closed the socket,
because the visibility handler treats only the remote screen, terminal
and wall pages as streaming pages. Returning to the tab reconnected the
socket, and the onopen restore path took its catch-all branch, which
navigated to the device list. The audit log therefore vanished whenever
the browser was minimized.

The catch-all branch now leaves the active page alone when it is the
audit log, and only refreshes the device list in the background. Every
other page keeps the previous behaviour: the login page advances with a
cached token, and the terminal page falls back to the device list
because its session died with the socket. Audit results already rendered
stay on screen, so the admin can keep reading without re-querying.

Co-Authored-By: deepseek-flash
2026-10-19 10:39:36 +02:00
yuanyuanxianganddeepseek-flash cd4f3675fd Improve: Replace native browser prompts with themed in-app dialogs
Native alert() and confirm() render their buttons in the browser or OS
locale, so on a German system prompts such as the Clear Screen Wall
confirmation showed German labels that disagreed with the rest of the
web UI. Both are now served by a single themed modal that reuses the
logout confirmation styling: an info variant with one OK button for
notices, and a danger variant with Cancel/OK for destructive actions.

The alert helper is fire-and-forget, while the confirm helper returns a
Promise, so the two call sites that need an answer (clearWall and
deleteUser) are now async. Dialog title and message are written with
textContent so device names, user names and server error strings can
never be interpreted as markup. A pending dialog is resolved as
cancelled when another one supersedes it, keeping callers from hanging,
and clicking the backdrop or pressing Escape closes the dialog. Remote
keyboard forwarding now ignores keys while a dialog is open.

Co-Authored-By: deepseek-flash
2026-10-19 10:27:09 +02:00
yuanyuanxianganddeepseek-flash bbb3d8c2fa Fix: Recover proxied client IPs and fit audit filters on phones
The MFC web server now resolves the real client address from the
WebSocket upgrade request when the TCP peer is loopback, so audit records
and login rate limiting stop reporting the reverse proxy's 127.0.0.1.
X-Forwarded-For is preferred over X-Real-IP because its last hop is the
address the local proxy appended; a client-supplied header list cannot
forge it. Forwarded headers are ignored for non-loopback peers so a
client connecting directly over the internet cannot spoof its identity
or rotate addresses to bypass throttling. FRP deployments can keep using
the existing Proxy Protocol v2 support instead.

Collapse the audit log search conditions behind a toolbar toggle that
starts closed and switches to an accent-colored "Hide filters" state
while the panel is open. On portrait phones the panel uses one control
per row, and From/To move their border and padding onto a wrapper so the
iOS WebKit datetime-local width miscalculation cannot overflow the
viewport.

Co-Authored-By: deepseek-flash
2026-10-19 10:05:58 +02:00
yuanyuanxianganddeepseek-flash ca671d6453 Feature: In-memory web audit log for the MFC server
Add a process-local security audit trail to the Windows server's web
console, mirroring the Go server's event names and reply shape. The ring
keeps the most recent 1000 events since process start and is never
persisted.

- New query_audit WebSocket command, admin-only, with time/event/actor/
  IP/device/keyword/warn-only filters and pagination; the reply is the
  audit_result payload the existing Audit Log page already understands.
  The challenge advertises caps.auditLog so browsers enable the button.
- Record logins (success, failure, throttling, salt probes), WebSocket
  connect/close, screen and terminal session start/stop/denials,
  clipboard transfers (byte counts only), RDP resets, and admin user
  mutations.
- Repeated failed logins and username probes are de-duplicated per
  connection so scanners cannot flood the ring.
- Audit writes never hold m_ClientsMutex: disconnect paths snapshot the
  client identity before the map entry is erased, and offline-device
  stops are audited after the clients lock is released.

Co-Authored-By: deepseek-flash
2026-10-18 19:16:17 +02:00
yuanyuanxianganddeepseek-flash a522424491 Feature: Security audit logging with admin web query page
Add a dedicated security audit trail to the Go server. Login attempts,
authorization denials, WebSocket lifecycle, remote screen and terminal
sessions, clipboard operations, device authentication and admin mutations
now emit stable JSON-line events (audit=true, event=<name>) to a separate
logs/audit.log, mirrored to server.log. The path is configurable through
YAMA_AUDIT_LOG and the separate file can be switched off. Records never
contain passcodes, password hashes, tokens or clipboard contents, and
untrusted string fields are stripped of control characters and truncated
so they cannot forge log lines.

Expose the trail to administrators through a query_audit WebSocket command
and an Audit Log page in the web UI. The page filters by time range, event
(exact or prefix), actor, IP, device id, keyword and warning level, pages
newest-first, and stays hidden unless the server advertises the auditLog
capability, so older C++ servers degrade cleanly. Each query scans at most
the newest 32 MB of the current file to bound latency, records itself as
admin.audit.query, and tolerates torn or non-audit lines.

Add unit tests for event emission and redaction, query filtering, paging,
malformed lines, missing or disabled audit files, and handler
authorization.

Co-Authored-By: deepseek-flash
2026-10-18 18:44:17 +02:00
yuanyuanxianganddeepseek-flash b1ddec1b8a Feature: Offline device history with delete and remote uninstall
Persist every device that has ever logged in to the Go server in a small
JSON store (devices.json, overridable with YAMA_DEVICES_FILE and capped by
YAMA_DEVICES_MAX, default 1000 newest). The store follows the users.json
convention: atomic temp-file write with fsync and rename, a 500 ms debounce
for bursts of reconnects, first_seen preserved across logins, and pruning
that evicts offline records before devices that are still connected.
Devices connected at shutdown are stamped offline before the final flush so
last_seen stays accurate across restarts.

The server advertises an offlineDevices capability and device_list now
appends historical devices that are not online, marked with online:false and
last_seen. The web UI shows an Offline checkbox after Total/Showing (state
kept in localStorage), renders offline cards dimmed and dashed, and always
lists them in the screen-wall sidebar. Older servers without the capability
keep the online-only list.

Grid and List cards gain a delete button after the terminal button, admin
only, behind a confirmation dialog. Deleting an offline device removes its
history record; deleting an online device first sends the single-byte
TOKEN_UNINSTALL command (matching the MFC OnOnlineUninstall flow) and then
removes the record too, so the device does not come back as a dead offline
entry. The agent's own disconnect then runs the existing cleanup path:
screen and terminal sub-connections are closed, the hub entry is removed,
and browsers receive device_offline to release their watch sets, wall
sessions, and stream references. Wall mode exposes no delete action.

Tests cover history persistence/pruning/recovery, offline ordering, the
device_list merge, and all three delete outcomes (offline removal, online
uninstall with a single 0x3F byte, and viewer rejection).

Co-Authored-By: deepseek-flash
2026-10-18 17:54:30 +02:00
25 changed files with 4263 additions and 150 deletions

No files matched your search

+1
View File
@@ -93,6 +93,7 @@ Bin/*
nul
server/go/web/assets/index.html
server/go/users.json
server/go/devices.json
server/go/build/
server/go/.claude/settings.json
android/app/.cxx/
+14 -7
View File
@@ -7445,8 +7445,10 @@ std::pair<std::string, std::string> CMy2015RemoteDlg::GenerateRenewalInfo(
newHmac = std::to_string(hmacVal);
}
// 新 passcode 的到期日是“今天 + days” (见上面的 newPasscode);
// 预设日期已过去时 days 被钳制为 1,日志应显示实际日期。
Mprintf("[TOKEN_AUTH] 生成续期: %s, %s -> %s, %d并发数, 配额剩余%d\n",
sn.c_str(), currentExpire.c_str(), renewal.ExpireDate.c_str(), renewal.HostNum, renewal.Quota - 1);
sn.c_str(), currentExpire.c_str(), getDateStr(days).c_str(), renewal.HostNum, renewal.Quota - 1);
// 配额递减,配额用完后自动清除预设续期记录
DecrementPendingQuota(sn);
@@ -7546,13 +7548,18 @@ void CMy2015RemoteDlg::SendPendingRenewal(CONTEXT_OBJECT* ctx, const std::string
int days = (int)span.GetDays();
if (days <= 0) days = 1;
// 客户端按“今天 + days”签发新授权(CMD_AUTHORIZATION 使用 getDateStr(0/days))。
// 预设日期若已经过去(过期授权在旧版本中设置的预设),实际到期日是今天 + 1,
// 日志和提示必须显示实际日期,而不是已失效的预设日期。
std::string actualExpire = getDateStr(days);
// 构建并发送 CMD_AUTHORIZATION
if (source) {
Mprintf("[%s] 自动下发续期: %s, %s -> %s, %d并发数\n",
source, sn.c_str(), currentExpire.c_str(), renewal.ExpireDate.c_str(), renewal.HostNum);
source, sn.c_str(), currentExpire.c_str(), actualExpire.c_str(), renewal.HostNum);
} else {
Mprintf("自动下发续期: %s, %s -> %s, %d并发数\n",
sn.c_str(), currentExpire.c_str(), renewal.ExpireDate.c_str(), renewal.HostNum);
sn.c_str(), currentExpire.c_str(), actualExpire.c_str(), renewal.HostNum);
}
BYTE bToken[32] = { CMD_AUTHORIZATION };
@@ -7571,10 +7578,10 @@ void CMy2015RemoteDlg::SendPendingRenewal(CONTEXT_OBJECT* ctx, const std::string
bool hasRemainingQuota = DecrementPendingQuota(sn);
Mprintf("[HEARTBEAT] 续期下发完成: %s, 剩余配额: %s\n", sn.c_str(), hasRemainingQuota ? "有" : "无");
// 提示续期已下发
std::string expireFmt = renewal.ExpireDate.substr(0, 4) + "-" +
renewal.ExpireDate.substr(4, 2) + "-" +
renewal.ExpireDate.substr(6, 2);
// 提示续期已下发(显示实际到期日)
std::string expireFmt = actualExpire.substr(0, 4) + "-" +
actualExpire.substr(4, 2) + "-" +
actualExpire.substr(6, 2);
std::string tip = sn + g_Lang.Get(std::string(" 已自动续期至 ")) + expireFmt;
PostMessageA(WM_SHOWMESSAGE, (WPARAM)new CharMsg(tip.c_str()), NULL);
}
+20 -5
View File
@@ -563,6 +563,17 @@ void CLicenseDlg::OnLicenseRenewal()
if (defaultHostNum <= 0) defaultHostNum = 100;
int defaultQuota = lic.PendingQuota > 0 ? lic.PendingQuota : 1;
// 判断当前授权是否已过期。续期从“今天”起算:客户端收到 days 后按
// getDateStr(0) + days 签发新授权(见 CMD_AUTHORIZATION 处理)。
// 若从已过去的原到期日加天数,预设日期会落在过去,下发时被
// SendPendingRenewal 钳制成 1 天,实际到期日与预设/提示不一致。
CTime now = CTime::GetCurrentTime();
CTime today(now.GetYear(), now.GetMonth(), now.GetDay(), 0, 0, 0);
CString strToday;
strToday.Format(_T("%04d%02d%02d"), now.GetYear(), now.GetMonth(), now.GetDay());
bool expired = currentExpireDate.length() == 8 &&
currentExpireDate < std::string(CT2A(strToday));
// 格式化当前过期日期显示
CString strCurrentExpire;
if (currentExpireDate.length() == 8) {
@@ -570,6 +581,9 @@ void CLicenseDlg::OnLicenseRenewal()
currentExpireDate.substr(0, 4).c_str(),
currentExpireDate.substr(4, 2).c_str(),
currentExpireDate.substr(6, 2).c_str());
if (expired) {
strCurrentExpire += _L(" (已过期,续期从今天起算)");
}
} else {
strCurrentExpire = _TR("当前到期: 未知");
}
@@ -595,15 +609,13 @@ void CLicenseDlg::OnLicenseRenewal()
return;
}
// 计算新的过期日期 (从原到期日期开始加天数)
CTime baseTime;
if (currentExpireDate.length() == 8) {
// 计算新的过期日期:未过期从原到期日加天数;已过期从今天起算。
CTime baseTime = today;
if (!expired && currentExpireDate.length() == 8) {
int year = atoi(currentExpireDate.substr(0, 4).c_str());
int month = atoi(currentExpireDate.substr(4, 2).c_str());
int day = atoi(currentExpireDate.substr(6, 2).c_str());
baseTime = CTime(year, month, day, 0, 0, 0);
} else {
baseTime = CTime::GetCurrentTime(); // 无法解析时用当前日期
}
CTimeSpan span(days, 0, 0, 0);
CTime newExpireTime = baseTime + span;
@@ -635,6 +647,9 @@ void CLicenseDlg::OnLicenseRenewal()
CString msg;
msg.FormatL("已预设续期至: %s\n并发连接数: %d\n配额: %d (支持%d台机器续期)\n客户端上线时将自动下发新授权",
strPending, hostNum, quota, quota);
if (expired) {
msg += _L("\n(原授权已过期,续期从今天起算)");
}
MessageBox(msg, _TR("预设成功"), MB_ICONINFORMATION);
}
}
+76
View File
@@ -349,6 +349,75 @@ public:
void onHttp(HttpHandler handler) { m_httpHandler = handler; }
private:
// ---- Real client IP behind a reverse proxy ----
// A same-host nginx/Caddy connects from 127.0.0.1 and reports the original
// peer in X-Real-IP / X-Forwarded-For. Only trust those headers when the
// TCP peer is loopback: otherwise any internet client could forge its
// address in the audit log and rotate it to evade login throttling.
static std::string trimHttpValue(const std::string& s) {
size_t b = s.find_first_not_of(" \t");
if (b == std::string::npos) return "";
size_t e = s.find_last_not_of(" \t\r\n");
return s.substr(b, e - b + 1);
}
static std::string asciiLower(const std::string& s) {
std::string out = s;
for (char& c : out) {
if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
}
return out;
}
// Case-insensitive lookup of one header in the raw request. The request
// line is first, so matching "\r\n<name>:" cannot hit the request target.
static std::string httpHeader(const std::string& request, const char* name) {
const std::string needle = "\r\n" + asciiLower(name) + ":";
const std::string hay = asciiLower(request);
size_t pos = hay.find(needle);
if (pos == std::string::npos) return "";
size_t vstart = pos + needle.size();
size_t vend = hay.find("\r\n", vstart);
return trimHttpValue(request.substr(vstart,
vend == std::string::npos ? std::string::npos : vend - vstart));
}
// Accept only a bare IPv4/IPv6 literal; forwarded headers are attacker
// controlled even when the proxy is trusted, so no hostnames/ports.
static std::string normalizeIPLiteral(const std::string& value) {
std::string s = trimHttpValue(value);
if (s.empty() || s.size() > 63) return "";
struct in_addr v4;
if (inet_pton(AF_INET, s.c_str(), &v4) == 1) return s;
struct in6_addr v6;
if (inet_pton(AF_INET6, s.c_str(), &v6) == 1) return s;
return "";
}
static std::string forwardedClientIP(const std::string& request) {
// X-Forwarded-For is "client, proxy1, ..." with the oldest hop first.
// Our own proxy appends the peer it saw, so the last valid entry is
// the one it actually observed: a client-supplied list cannot forge
// it. Prefer this over X-Real-IP, which is only trustworthy when the
// proxy explicitly overwrites it (some configs pass it through).
std::string xff = httpHeader(request, "x-forwarded-for");
size_t end = xff.size();
while (end > 0) {
size_t comma = xff.rfind(',', end - 1);
size_t start = comma == std::string::npos ? 0 : comma + 1;
std::string ip = normalizeIPLiteral(xff.substr(start, end - start));
if (!ip.empty()) return ip;
if (comma == std::string::npos) break;
end = comma;
}
return normalizeIPLiteral(httpHeader(request, "x-real-ip"));
}
static bool isLoopbackPeer(const std::string& ip) {
return ip.rfind("127.", 0) == 0 || ip == "::1" ||
ip == "::ffff:127.0.0.1";
}
// Parse Proxy Protocol v2 header and extract real client IP
bool parseProxyProtocolV2(SOCKET sock, std::string& clientIP) {
// PP2 signature (12 bytes)
@@ -448,6 +517,13 @@ private:
if (request.find("\r\n\r\n") != std::string::npos) break;
}
// Behind a same-host reverse proxy the TCP peer is 127.0.0.1; recover
// the real client address the proxy recorded in the handshake.
if (isLoopbackPeer(clientIP)) {
std::string forwarded = forwardedClientIP(request);
if (!forwarded.empty()) clientIP = forwarded;
}
// Check if WebSocket upgrade
if (request.find("Upgrade: websocket") == std::string::npos) {
// Regular HTTP request - extract path
+450 -11
View File
@@ -14,6 +14,8 @@
#include <fstream>
#include <shlobj.h>
#include <set>
#include <deque>
#include <cstdio>
// Algorithm constants (same as ScreenSpyDlg.cpp)
#define ALGORITHM_H264 2
@@ -66,6 +68,165 @@ static std::string ParseReqField(const Json::Value& root) {
return "";
}
//////////////////////////////////////////////////////////////////////////
// Web audit trail (in-memory ring)
//
// Keeps the most recent kMaxAuditEntries security-relevant operations made
// through the web console: logins, screen/terminal sessions, clipboard and
// RDP actions, and admin mutations. The buffer starts empty when the server
// process starts and is deliberately not persisted — it is a bounded live
// view for spotting suspicious activity (failed logins, unexpected viewers).
// Passwords, tokens and clipboard contents are never recorded.
//////////////////////////////////////////////////////////////////////////
namespace {
constexpr size_t kMaxAuditEntries = 1000;
struct AuditEntry {
int64_t ts = 0; // epoch seconds (UTC)
std::string time; // "2026-10-18T16:44:17.000Z"
std::string level; // "info" | "warn"
std::string event;
std::string actor;
std::string role;
std::string ip;
std::string device_id;
std::string conn;
std::vector<std::pair<std::string, std::string>> extra;
};
struct AuditQuery {
int64_t from = -1; // epoch seconds, -1 = unset
int64_t to = -1;
std::string event;
std::string actor;
std::string ip;
std::string device_id;
std::string keyword;
bool warn_only = false;
int limit = 100;
int offset = 0;
};
struct AuditQueryResult {
std::vector<AuditEntry> events; // newest first
int total = 0; // all matches in the ring
};
// Replace control characters and cap length so an attacker-controlled
// username / keyword cannot corrupt the record or bloat memory.
std::string SanitizeAuditString(const std::string& s, size_t maxBytes = 512) {
std::string out;
out.reserve(s.size() < maxBytes ? s.size() : maxBytes);
for (char ch : s) {
if (out.size() >= maxBytes) break;
unsigned char c = (unsigned char)ch;
out.push_back((c < 0x20 || c == 0x7f) ? ' ' : ch);
}
return out;
}
std::string FormatAuditTimeUtc(int64_t ts) {
time_t t = (time_t)ts;
struct tm tmv;
if (gmtime_s(&tmv, &t) != 0) return std::string();
char buf[40];
snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.000Z",
tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday,
tmv.tm_hour, tmv.tm_min, tmv.tm_sec);
return std::string(buf);
}
// Accepts the ISO-8601 forms the browser sends (Date.toISOString() or a
// datetime-local converted in JS). Returns -1 when the value is unusable.
int64_t ParseAuditTimeUtc(const std::string& s) {
int y = 0, mo = 0, d = 0, h = 0, mi = 0, se = 0;
// Width-limited conversions: a hostile caller cannot overflow the ints.
if (sscanf(s.c_str(), "%4d-%2d-%2dT%2d:%2d:%2d", &y, &mo, &d, &h, &mi, &se) != 6) return -1;
if (y < 1970 || mo < 1 || mo > 12 || d < 1 || d > 31 ||
h < 0 || h > 23 || mi < 0 || mi > 59 || se < 0 || se > 59) return -1;
struct tm tmv = {};
tmv.tm_year = y - 1900;
tmv.tm_mon = mo - 1;
tmv.tm_mday = d;
tmv.tm_hour = h;
tmv.tm_min = mi;
tmv.tm_sec = se;
time_t t = _mkgmtime(&tmv);
return t == (time_t)-1 ? -1 : (int64_t)t;
}
std::string ToLowerAscii(std::string s) {
for (char& c : s) {
if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
}
return s;
}
bool AuditEventMatches(const std::string& filter, const std::string& event) {
if (filter.empty()) return true;
// "web.*" and "web." are prefixes; anything else is an exact match.
if (filter.back() == '*') return event.rfind(filter.substr(0, filter.size() - 1), 0) == 0;
if (filter.back() == '.') return event.rfind(filter, 0) == 0;
return event == filter;
}
bool AuditEntryMatches(const AuditEntry& e, const AuditQuery& q) {
if (q.from >= 0 && e.ts < q.from) return false;
if (q.to >= 0 && e.ts > q.to) return false;
if (!AuditEventMatches(q.event, e.event)) return false;
if (!q.actor.empty() && ToLowerAscii(e.actor) != ToLowerAscii(q.actor)) return false;
if (!q.ip.empty() && e.ip != q.ip) return false;
if (!q.device_id.empty() && e.device_id != q.device_id) return false;
if (q.warn_only && e.level != "warn") return false;
if (!q.keyword.empty()) {
std::string hay = e.event + " " + e.actor + " " + e.role + " " + e.ip + " " +
e.device_id + " " + e.conn;
for (const auto& kv : e.extra) hay += " " + kv.first + "=" + kv.second;
if (ToLowerAscii(hay).find(ToLowerAscii(q.keyword)) == std::string::npos) return false;
}
return true;
}
class WebAuditRing {
public:
static WebAuditRing& Instance() {
static WebAuditRing ring;
return ring;
}
void Add(AuditEntry e) {
e.time = FormatAuditTimeUtc(e.ts);
std::lock_guard<std::mutex> lock(m_Mutex);
m_Entries.push_back(std::move(e));
while (m_Entries.size() > kMaxAuditEntries) {
m_Entries.pop_front();
}
}
AuditQueryResult Query(const AuditQuery& q) {
AuditQueryResult res;
std::lock_guard<std::mutex> lock(m_Mutex);
// Iterate newest-first; skip the first `offset` matches and collect
// up to `limit`, while still counting every match for the footer.
for (auto it = m_Entries.rbegin(); it != m_Entries.rend(); ++it) {
if (!AuditEntryMatches(*it, q)) continue;
if (res.total >= q.offset && (int)res.events.size() < q.limit) {
res.events.push_back(*it);
}
res.total++;
}
return res;
}
private:
std::mutex m_Mutex;
std::deque<AuditEntry> m_Entries;
};
} // namespace
// Challenge-response nonce storage (prevents replay attacks)
static std::map<void*, std::string> s_ClientNonces;
static std::mutex s_NonceMutex;
@@ -362,9 +523,12 @@ void CWebService::ServerThread(int port) {
// 缺少这些字段的服务端(Go / 旧 C++ 二进制)会让新前端自动降级。
challenge["caps"]["multiWatch"] = true;
challenge["caps"]["keyframeRequest"] = true;
challenge["caps"]["auditLog"] = true;
Json::StreamWriterBuilder builder;
builder["indentation"] = "";
SendText(ws_ptr, Json::writeString(builder, challenge));
AuditWeb("web.ws.connect", false, "", "", conn->clientIP(), ws_ptr, 0);
});
// WebSocket disconnect
@@ -443,6 +607,8 @@ void CWebService::ServerThread(int port) {
HandleTermResize(ws_ptr, msg);
} else if (cmd == "term_close") {
HandleTermClose(ws_ptr, msg);
} else if (cmd == "query_audit") {
HandleQueryAudit(ws_ptr, msg);
}
}
});
@@ -527,12 +693,27 @@ void CWebService::HandleLogin(void* ws_ptr, const std::string& msg, const std::s
// Verify nonce matches (prevents replay)
std::string storedNonce = ConsumeNonce(ws_ptr);
if (storedNonce.empty() || storedNonce != clientNonce) {
AuditWeb("web.login.failure", true, username, "", client_ip, ws_ptr, 0,
{{"reason", "invalid_challenge"}});
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid or expired challenge"));
return;
}
// Check rate limit
if (!CheckRateLimit(client_ip)) {
bool first = false;
{
std::lock_guard<std::mutex> lock(m_ClientsMutex);
auto it = m_Clients.find(ws_ptr);
if (it != m_Clients.end() && !it->second.login_throttle_logged) {
it->second.login_throttle_logged = true;
first = true;
}
}
if (first) {
AuditWeb("web.login.throttled", true, username, "", client_ip, ws_ptr, 0,
{{"reason", "rate_limited"}});
}
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Too many failed attempts. Try again later."));
return;
}
@@ -554,6 +735,8 @@ void CWebService::HandleLogin(void* ws_ptr, const std::string& msg, const std::s
if (!user) {
RecordFailedLogin(client_ip);
AuditWeb("web.login.failure", true, username, "", client_ip, ws_ptr, 0,
{{"reason", "unknown_user"}});
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid credentials"));
return;
}
@@ -562,6 +745,8 @@ void CWebService::HandleLogin(void* ws_ptr, const std::string& msg, const std::s
std::string expectedResponse = WSAuth::ComputeSHA256(user->password_hash + clientNonce);
if (response != expectedResponse) {
RecordFailedLogin(client_ip);
AuditWeb("web.login.failure", true, username, "", client_ip, ws_ptr, 0,
{{"reason", "bad_password"}});
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid credentials"));
return;
}
@@ -581,6 +766,8 @@ void CWebService::HandleLogin(void* ws_ptr, const std::string& msg, const std::s
}
}
AuditWeb("web.login.success", false, username, user->role, client_ip, ws_ptr, 0);
// Build response
Json::Value res;
res["cmd"] = "login_result";
@@ -628,6 +815,24 @@ void CWebService::HandleGetSalt(void* ws_ptr, const std::string& msg) {
if (!userFound) {
// Generate deterministic fake salt from username (won't match any real password)
salt = WSAuth::ComputeSHA256("fake_salt_prefix_" + username).substr(0, 16);
// Username enumeration probe. Scanners can repeat this in a tight loop,
// so record at most one per connection.
bool first = false;
std::string ip;
{
std::lock_guard<std::mutex> lock(m_ClientsMutex);
auto it = m_Clients.find(ws_ptr);
if (it != m_Clients.end() && !it->second.salt_probe_logged) {
it->second.salt_probe_logged = true;
first = true;
ip = it->second.client_ip;
}
}
if (first) {
AuditWeb("web.salt.unknown_user", true, "", "", ip, ws_ptr, 0,
{{"username", username}, {"reason", "unknown_user"}});
}
}
Json::Value res;
@@ -703,6 +908,8 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
}
}
if (!hasAccess) {
AuditWeb("remote.screen.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
{{"reason", "group_permission"}});
send_error("Permission denied");
return;
}
@@ -713,6 +920,8 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
// remote_open, cleared on session teardown), so AI input cannot be injected into a
// picture a human is watching/controlling.
if (IsMcpTriggered(device_id)) {
AuditWeb("remote.screen.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
{{"reason", "mcp_session_active"}});
send_error("Device busy: remote control session active");
return;
}
@@ -731,6 +940,8 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
// Check max clients per device
int current_count = GetWebClientCount(device_id);
if (!already && current_count >= m_nMaxClientsPerDevice) {
AuditWeb("remote.screen.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
{{"reason", "too_many_viewers"}});
send_error("Too many viewers");
return;
}
@@ -760,12 +971,19 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
}
}
if (too_many_watch) {
AuditWeb("remote.screen.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
{{"reason", "too_many_watches"}});
send_error("Too many devices on one connection");
return;
}
// StopRemoteDesktop 会再次锁 m_ClientsMutex,必须在锁外调用。
for (uint64_t id : to_stop) {
StopRemoteDesktop(id);
if (!to_stop.empty()) {
std::string ip = ClientIP(ws_ptr);
for (uint64_t id : to_stop) {
StopRemoteDesktop(id);
AuditWeb("remote.screen.stop", false, username, role, ip, ws_ptr, id,
{{"reason", "replaced"}});
}
}
// Start remote desktop session if this is the first web viewer. 若之前已订阅
@@ -782,11 +1000,20 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
it->second.watch_device_ids.erase(device_id);
}
}
AuditWeb("remote.screen.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
{{"reason", "start_failed"}});
send_error("Failed to start remote desktop");
return;
}
}
if (!already) {
AuditWeb("remote.screen.start", false, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
{{"mode", need_start ? "started" : "attached"},
{"viewers", std::to_string(current_count + 1)},
{"multi_watch", multi_watch ? "true" : "false"}});
}
// Get screen dimensions + audio state from device info cache (may not be ready)
int width = 0, height = 0;
int audio_enabled = -1; // -1 = unknown yet (前端走 audio_state 事件兜底)
@@ -891,6 +1118,13 @@ void CWebService::HandleDisconnect(void* ws_ptr, const std::string& token, uint6
for (uint64_t id : to_stop) {
StopRemoteDesktop(id);
}
if (!to_stop.empty()) {
std::string ip = ClientIP(ws_ptr);
for (uint64_t id : to_stop) {
AuditWeb("remote.screen.stop", false, username, role, ip, ws_ptr, id,
{{"reason", "disconnect"}});
}
}
send_reply(true, "");
}
@@ -1086,6 +1320,10 @@ void CWebService::HandleClipboardSet(void* ws_ptr, const std::string& msg) {
WideCharToMultiByte(936, 0, wide.c_str(), wlen, (char*)szData.data() + 1, glen, nullptr, nullptr);
ctx->Send2Client(szData.data(), (ULONG)szData.size());
// Never record clipboard contents -- only the transferred size.
AuditWeb("remote.clipboard.set", false, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
{{"bytes", std::to_string((size_t)glen)}});
}
void CWebService::HandleClipboardGet(void* ws_ptr, const std::string& token, uint64_t requested_device_id) {
@@ -1108,6 +1346,8 @@ void CWebService::HandleClipboardGet(void* ws_ptr, const std::string& token, uin
// 单字节命令,客户端以 SendClientClipboard → TOKEN_CLIPBOARD_TEXT 回包
BYTE bToken = COMMAND_SCREEN_GET_CLIPBOARD;
ctx->Send2Client(&bToken, sizeof(BYTE));
AuditWeb("remote.clipboard.get", false, username, role, ClientIP(ws_ptr), ws_ptr, device_id);
}
void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token, uint64_t requested_device_id) {
@@ -1136,8 +1376,12 @@ void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token, uint64_
BYTE bToken = CMD_RESTORE_CONSOLE;
if (ctx->Send2Client(&bToken, 1)) {
Mprintf("[WebService] Sent RDP reset command to device %llu\n", device_id);
AuditWeb("remote.rdp_reset", false, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
{{"ok", "true"}});
SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", true));
} else {
AuditWeb("remote.rdp_reset", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
{{"ok", "false"}});
SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", false, "Failed to send command"));
}
}
@@ -1257,6 +1501,8 @@ void CWebService::HandleCreateUser(void* ws_ptr, const std::string& msg) {
// Only admin can create users
if (role != "admin") {
AuditWeb("web.authz.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, 0,
{{"operation", "create_user"}});
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Permission denied"));
return;
}
@@ -1281,7 +1527,12 @@ void CWebService::HandleCreateUser(void* ws_ptr, const std::string& msg) {
return;
}
if (CreateUser(newUsername, newPassword, newRole, allowedGroups)) {
bool created = CreateUser(newUsername, newPassword, newRole, allowedGroups);
AuditWeb("admin.user.create", !created, username, role, ClientIP(ws_ptr), ws_ptr, 0,
{{"target_user", newUsername}, {"target_role", newRole},
{"groups", std::to_string(allowedGroups.size())},
{"ok", created ? "true" : "false"}});
if (created) {
SendText(ws_ptr, BuildJsonResponse("create_user_result", true));
} else {
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Failed to create user (may already exist)"));
@@ -1305,13 +1556,18 @@ void CWebService::HandleDeleteUser(void* ws_ptr, const std::string& msg) {
// Only admin can delete users
if (role != "admin") {
AuditWeb("web.authz.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, 0,
{{"operation", "delete_user"}});
SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Permission denied"));
return;
}
std::string targetUsername = root.get("username", "").asString();
if (DeleteUser(targetUsername)) {
bool deleted = DeleteUser(targetUsername);
AuditWeb("admin.user.delete", !deleted, username, role, ClientIP(ws_ptr), ws_ptr, 0,
{{"target_user", targetUsername}, {"ok", deleted ? "true" : "false"}});
if (deleted) {
SendText(ws_ptr, BuildJsonResponse("delete_user_result", true));
} else {
SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Failed to delete user"));
@@ -1327,6 +1583,8 @@ void CWebService::HandleListUsers(void* ws_ptr, const std::string& token) {
// Only admin can list users
if (role != "admin") {
AuditWeb("web.authz.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, 0,
{{"operation", "list_users"}});
SendText(ws_ptr, BuildJsonResponse("list_users_result", false, "Permission denied"));
return;
}
@@ -1370,6 +1628,8 @@ void CWebService::HandleGetGroups(void* ws_ptr, const std::string& token) {
// Only admin can get groups list (for user management)
if (role != "admin") {
AuditWeb("web.authz.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, 0,
{{"operation", "get_groups"}});
SendText(ws_ptr, BuildJsonResponse("groups", false, "Permission denied"));
return;
}
@@ -1403,6 +1663,132 @@ void CWebService::HandleGetGroups(void* ws_ptr, const std::string& token) {
SendText(ws_ptr, json);
}
//////////////////////////////////////////////////////////////////////////
// Security Audit Trail
//////////////////////////////////////////////////////////////////////////
std::string CWebService::ClientIP(void* ws_ptr) {
std::lock_guard<std::mutex> lock(m_ClientsMutex);
auto it = m_Clients.find(ws_ptr);
return it != m_Clients.end() ? it->second.client_ip : std::string();
}
void CWebService::AuditWeb(const std::string& event, bool warn,
const std::string& actor, const std::string& role,
const std::string& ip, void* ws_ptr, uint64_t device_id,
std::initializer_list<std::pair<const char*, std::string>> extra) {
AuditEntry e;
e.ts = (int64_t)time(nullptr);
e.level = warn ? "warn" : "info";
e.event = SanitizeAuditString(event, 96);
e.actor = SanitizeAuditString(actor, 128);
e.role = SanitizeAuditString(role, 32);
e.ip = SanitizeAuditString(ip, 64);
if (device_id != 0) e.device_id = std::to_string(device_id);
if (ws_ptr != nullptr) {
char buf[32];
snprintf(buf, sizeof(buf), "%p", ws_ptr);
e.conn = buf;
}
for (const auto& kv : extra) {
std::string key = SanitizeAuditString(kv.first, 64);
if (key.empty()) continue;
// Never let a caller shadow the fixed schema fields.
if (key == "time" || key == "level" || key == "event" || key == "actor" ||
key == "role" || key == "ip" || key == "device_id" || key == "conn") {
continue;
}
e.extra.emplace_back(std::move(key), SanitizeAuditString(kv.second));
}
WebAuditRing::Instance().Add(std::move(e));
}
void CWebService::HandleQueryAudit(void* ws_ptr, const std::string& msg) {
static const char* replyCmd = "audit_result";
Json::Value root;
Json::Reader reader;
if (!reader.parse(msg, root)) {
SendText(ws_ptr, BuildJsonResponse(replyCmd, false, "Invalid JSON"));
return;
}
std::string token = root["token"].isString() ? root["token"].asString() : "";
std::string username, role;
if (!ValidateToken(token, username, role)) {
SendText(ws_ptr, BuildJsonResponse(replyCmd, false, "Invalid token"));
return;
}
if (role != "admin") {
AuditWeb("web.authz.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, 0,
{{"operation", "query_audit"}, {"reason", "not_admin"}});
SendText(ws_ptr, BuildJsonResponse(replyCmd, false, "Permission denied"));
return;
}
AuditQuery q;
std::string from = root["from"].isString() ? root["from"].asString() : "";
std::string to = root["to"].isString() ? root["to"].asString() : "";
if (!from.empty()) q.from = ParseAuditTimeUtc(from);
if (!to.empty()) q.to = ParseAuditTimeUtc(to);
q.event = root["event"].isString() ? root["event"].asString() : "";
q.actor = root["actor"].isString() ? root["actor"].asString() : "";
q.ip = root["ip"].isString() ? root["ip"].asString() : "";
q.device_id = root["device_id"].isString() ? root["device_id"].asString() : "";
q.keyword = root["keyword"].isString() ? root["keyword"].asString() : "";
q.warn_only = root["warn_only"].isBool() && root["warn_only"].asBool();
// Read as double first: asInt() on a huge JSON number can throw.
double limit_d = root["limit"].isNumeric() ? root["limit"].asDouble() : 100.0;
double offset_d = root["offset"].isNumeric() ? root["offset"].asDouble() : 0.0;
q.limit = limit_d <= 0.0 ? 100 : (int)(limit_d > 500.0 ? 500.0 : limit_d);
q.offset = offset_d < 0.0 ? 0 : (int)(offset_d > 10000.0 ? 10000.0 : offset_d);
AuditQueryResult r = WebAuditRing::Instance().Query(q);
Json::Value events(Json::arrayValue);
for (const auto& e : r.events) {
Json::Value ev;
ev["time"] = e.time;
ev["level"] = e.level;
ev["event"] = e.event;
if (!e.actor.empty()) ev["actor"] = e.actor;
if (!e.role.empty()) ev["role"] = e.role;
if (!e.ip.empty()) ev["ip"] = e.ip;
if (!e.device_id.empty()) ev["device_id"] = e.device_id;
if (!e.conn.empty()) ev["conn"] = e.conn;
for (const auto& kv : e.extra) ev[kv.first] = kv.second;
events.append(ev);
}
// Reading the trail is itself an auditable action. This record is added
// after the snapshot, so it does not appear in this reply.
AuditWeb("admin.audit.query", false, username, role, ClientIP(ws_ptr), ws_ptr, 0,
{{"event_filter", q.event},
{"actor_filter", q.actor},
{"ip_filter", q.ip},
{"device_filter", q.device_id},
{"keyword", q.keyword},
{"warn_only", q.warn_only ? "true" : "false"},
{"matches", std::to_string(r.total)},
{"returned", std::to_string(r.events.size())},
{"offset", std::to_string(q.offset)}});
Json::Value res;
res["cmd"] = replyCmd;
res["ok"] = true;
res["events"] = events;
res["total"] = r.total;
res["offset"] = q.offset;
res["limit"] = q.limit;
res["file"] = "memory (last 1000 events since server startup)";
res["file_exists"] = true;
res["truncated"] = false;
Json::StreamWriterBuilder builder;
builder["indentation"] = "";
SendText(ws_ptr, Json::writeString(builder, res));
}
//////////////////////////////////////////////////////////////////////////
// Token Management (delegated to WebServiceAuth module)
//////////////////////////////////////////////////////////////////////////
@@ -1430,6 +1816,8 @@ void CWebService::RegisterClient(void* ws_ptr, const std::string& client_ip) {
void CWebService::UnregisterClient(void* ws_ptr) {
std::set<uint64_t> watched;
std::string username, role, ip;
uint64_t connected_at = 0;
// Copy the watch set and remove the client under the lock.
{
@@ -1437,6 +1825,10 @@ void CWebService::UnregisterClient(void* ws_ptr) {
auto it = m_Clients.find(ws_ptr);
if (it != m_Clients.end()) {
watched = std::move(it->second.watch_device_ids);
username = it->second.username;
role = it->second.role;
ip = it->second.client_ip;
connected_at = it->second.connected_at;
m_Clients.erase(it);
}
}
@@ -1445,6 +1837,8 @@ void CWebService::UnregisterClient(void* ws_ptr) {
// StopRemoteDesktop re-locks m_ClientsMutex, so this must run outside the lock.
for (uint64_t device_id : watched) {
StopRemoteDesktop(device_id);
AuditWeb("remote.screen.stop", false, username, role, ip, ws_ptr, device_id,
{{"reason", "ws_close"}});
}
// 关闭这个 web client 持有的所有终端会话(MVP 一个用户一台主机一个,但兜底全扫描)
@@ -1454,7 +1848,17 @@ void CWebService::UnregisterClient(void* ws_ptr) {
for (auto& kv : m_TermSessions) {
if (kv.second.ws_ptr == ws_ptr) to_close.push_back(kv.first);
}
for (uint64_t did : to_close) CloseTermSessionLocked(did);
for (uint64_t did : to_close) CloseTermSessionLocked(did, "ws_close", username, role, ip);
}
// Anonymous connections and rejected scanners are noisy; only authenticated
// sessions get a close record (mirrors the Go server's policy). Written last
// so the trail reads connect -> ... -> stops -> close for one connection.
if (!username.empty()) {
uint64_t now = (uint64_t)time(nullptr);
AuditWeb("web.ws.close", false, username, role, ip, ws_ptr, 0,
{{"duration_ms", std::to_string((now > connected_at ? now - connected_at : 0) * 1000)},
{"screens", std::to_string(watched.size())}});
}
}
@@ -2123,6 +2527,8 @@ void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) {
}
context* ctx = m_pParentDlg->FindHost(device_id);
if (!ctx) {
AuditWeb("remote.terminal.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
{{"reason", "device_offline"}});
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Device offline"));
return;
}
@@ -2139,6 +2545,8 @@ void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) {
}
}
if (!ok) {
AuditWeb("remote.terminal.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
{{"reason", "group_permission"}});
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Permission denied"));
return;
}
@@ -2149,6 +2557,8 @@ void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) {
std::lock_guard<std::mutex> lk(m_TermMutex);
if (m_TermSessions.find(device_id) != m_TermSessions.end() ||
m_TermPending.find(device_id) != m_TermPending.end()) {
AuditWeb("remote.terminal.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
{{"reason", "terminal_busy"}});
SendText(ws_ptr, BuildJsonResponse("term_closed", false,
"Terminal already open by another viewer"));
return;
@@ -2162,13 +2572,18 @@ void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) {
// 触发客户端:发 COMMAND_SHELL
BYTE cmd = COMMAND_SHELL;
if (!ctx->Send2Client(&cmd, 1)) {
std::lock_guard<std::mutex> lk(m_TermMutex);
m_TermSessions.erase(device_id);
m_TermPending.erase(device_id);
{
std::lock_guard<std::mutex> lk(m_TermMutex);
m_TermSessions.erase(device_id);
m_TermPending.erase(device_id);
}
AuditWeb("remote.terminal.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id,
{{"reason", "send_failed"}});
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Send failed"));
return;
}
Mprintf("[WebService] term_open device=%llu user=%s\n", device_id, username.c_str());
AuditWeb("remote.terminal.start", false, username, role, ClientIP(ws_ptr), ws_ptr, device_id);
}
void CWebService::HandleTermInput(void* ws_ptr, const std::string& msg) {
@@ -2233,10 +2648,12 @@ void CWebService::HandleTermClose(void* ws_ptr, const std::string& msg) {
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
CloseTermSessionLocked(device_id);
CloseTermSessionLocked(device_id, "user");
}
void CWebService::CloseTermSessionLocked(uint64_t device_id) {
void CWebService::CloseTermSessionLocked(uint64_t device_id, const char* reason,
const std::string& actor, const std::string& role,
const std::string& ip) {
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end()) return;
@@ -2253,6 +2670,20 @@ void CWebService::CloseTermSessionLocked(uint64_t device_id) {
// 通知前端
SendText(ws_ptr, BuildJsonResponse("term_closed", true, "closed"));
Mprintf("[WebService] term_closed device=%llu\n", device_id);
// 身份可能已随 ws 断开被删除(UnregisterClient 路径),此时调用方会传入。
std::string username = actor, role_name = role, peer_ip = ip;
if (username.empty()) {
std::lock_guard<std::mutex> lk(m_ClientsMutex);
auto cit = m_Clients.find(ws_ptr);
if (cit != m_Clients.end()) {
username = cit->second.username;
role_name = cit->second.role;
peer_ip = cit->second.client_ip;
}
}
AuditWeb("remote.terminal.stop", false, username, role_name, peer_ip, ws_ptr, device_id,
{{"reason", reason ? reason : ""}});
}
bool CWebService::IsTermPending(uint64_t device_id) {
@@ -2329,7 +2760,7 @@ void CWebService::OnTerminalClosed(CONTEXT_OBJECT* ctx) {
auto it = m_TermContextToDevice.find(ctx);
if (it == m_TermContextToDevice.end()) return;
uint64_t device_id = it->second;
CloseTermSessionLocked(device_id);
CloseTermSessionLocked(device_id, "remote_closed");
}
//////////////////////////////////////////////////////////////////////////
@@ -2392,6 +2823,8 @@ void CWebService::FlushDeviceChanges() {
}
// Notify clients watching offline devices
struct OfflineAudit { void* ws; std::string actor, role, ip; uint64_t id; };
std::vector<OfflineAudit> offline_audits;
if (!offline.empty()) {
std::lock_guard<std::mutex> lock(m_ClientsMutex);
for (auto& [ws_ptr, client] : m_Clients) {
@@ -2409,9 +2842,15 @@ void CWebService::FlushDeviceChanges() {
SendText(ws_ptr, Json::writeString(builder, res));
client.watch_device_ids.erase(id);
client.keyframe_req_at.erase(id);
offline_audits.push_back({ws_ptr, client.username, client.role, client.client_ip, id});
}
}
}
// AuditWeb 自身会锁 m_ClientsMutex,必须在释放后调用。
for (const auto& a : offline_audits) {
AuditWeb("remote.screen.stop", false, a.actor, a.role, a.ip, a.ws, a.id,
{{"reason", "device_offline"}});
}
// Clear offline devices from cache
{
+21 -2
View File
@@ -5,6 +5,8 @@
#include <vector>
#include <map>
#include <set>
#include <initializer_list>
#include <utility>
#include <atomic>
#include <chrono>
#include <mutex>
@@ -76,6 +78,10 @@ struct WebClient {
uint64_t connected_at;
uint64_t last_activity; // Last message/pong received (for heartbeat timeout)
std::string client_ip;
// Per-connection audit de-duplication: probes an attacker can repeat in a
// tight loop are recorded once per connection instead of flooding the ring.
bool salt_probe_logged = false;
bool login_throttle_logged = false;
WebClient() : connected_at(0), last_activity(0) {}
};
@@ -239,6 +245,15 @@ private:
void HandleListUsers(void* ws_ptr, const std::string& token);
void HandleGetGroups(void* ws_ptr, const std::string& token);
// Security audit trail: in-memory ring of the last 1000 events since
// startup (see WebService.cpp). query_audit is admin-only.
void HandleQueryAudit(void* ws_ptr, const std::string& msg);
std::string ClientIP(void* ws_ptr);
void AuditWeb(const std::string& event, bool warn,
const std::string& actor, const std::string& role,
const std::string& ip, void* ws_ptr, uint64_t device_id,
std::initializer_list<std::pair<const char*, std::string>> extra = {});
// Send to WebSocket
void SendText(void* ws_ptr, const std::string& text);
void SendBinary(void* ws_ptr, const uint8_t* data, size_t len);
@@ -388,8 +403,12 @@ private:
std::set<uint64_t> m_TermPending; // 已发 COMMAND_SHELL 待响应
std::mutex m_TermMutex;
// 内部清理(已持锁版本)
void CloseTermSessionLocked(uint64_t device_id);
// 内部清理(已持锁版本)。reason/actor/role/ip 仅用于审计日志;actor 为空时
// 尝试从 m_Clients 反查(ws 已注销时需由调用方传入)。
void CloseTermSessionLocked(uint64_t device_id, const char* reason = "ws_close",
const std::string& actor = std::string(),
const std::string& role = std::string(),
const std::string& ip = std::string());
};
// Global accessor
+42 -1
View File
@@ -141,7 +141,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
./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_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_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>` |
```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)"}
```
### 安全审计日志
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++ 版本兼容:
+121 -17
View File
@@ -18,6 +18,7 @@ import (
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
"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/licensing"
"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 {
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()
}
@@ -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
// other than the device's login conn. Log the latter distinctly.
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",
devID, ctx.ID, ctx.GetPeerIP(), mc.GetPeerIP())
h.log.AuditWarn("device.subconn.foreign_ip", map[string]any{
"kind": "terminal",
"device_id": devID,
"conn": ctx.ID,
"ip": ctx.GetPeerIP(),
"want_ip": mc.GetPeerIP(),
})
} else {
h.log.Warn("orphan terminal sub-conn: device=%s conn=%d isPTY=%v",
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.
// The latter is a security signal worth surfacing distinctly.
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",
deviceID, ctx.ID, ctx.GetPeerIP(), mc.GetPeerIP())
h.log.AuditWarn("device.subconn.foreign_ip", map[string]any{
"kind": "screen",
"device_id": deviceID,
"conn": ctx.ID,
"ip": ctx.GetPeerIP(),
"want_ip": mc.GetPeerIP(),
})
} else {
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.
ip := ctx.GetPeerIP()
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: "尝试过于频繁,请稍后再试"})
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)
@@ -629,12 +649,23 @@ func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) {
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 {
h.log.Info("Auth %s success: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode)
h.log.Audit("device.auth.success", fields)
} else {
h.log.Warn("Auth %s failed: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode)
h.log.AuditWarn("device.auth.failure", fields)
}
// Build and send response
@@ -706,15 +737,22 @@ func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) {
if !ctx.IsAuthorized.Load() {
ctx.IsAuthorized.Store(true)
info := ctx.GetInfo()
version := "V1"
if authResult.IsV2 {
// V2 authorization
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)
version = "V2"
}
// 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.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.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
// 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.
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:
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
// 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
// device registry; the authenticator owns user accounts and session tokens.
httpSrv := web.New(*httpPort, log.WithPrefix("Web"), deviceHub, webAuth).
WithDeviceHistory(deviceHistory).
WithConfig(webCfg)
if err := httpSrv.Start(); err != nil {
log.Fatal("Failed to start HTTP server: %v", err)
@@ -1227,5 +1316,20 @@ func main() {
for _, srv := range servers {
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")
// Flush and release the rotated log handles. Without this the last audit
// records can sit in lumberjack's buffered writer on shutdown.
_ = log.Close()
}
+373
View File
@@ -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())
}
}
}
+14
View File
@@ -165,6 +165,9 @@ type DeviceInfo struct {
ActiveWindow string `json:"activeWindow,omitempty"`
ConnectedAt int64 `json:"connected_at"`
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
@@ -240,6 +243,17 @@ func (h *Hub) SetSender(fn SendFunc) {
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
// main connection. data should be the raw command bytes (the sender takes
// care of framing / compression at the protocol layer).
+185 -16
View File
@@ -1,10 +1,12 @@
package logger
import (
"fmt"
"io"
"os"
"path/filepath"
"time"
"strings"
"unicode/utf8"
"github.com/rs/zerolog"
"gopkg.in/natefinch/lumberjack.v2"
@@ -24,6 +26,13 @@ const (
// Logger wraps zerolog.Logger
type Logger struct {
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
@@ -42,6 +51,19 @@ type Config struct {
MaxAge int
// Compress enables gzip compression for rotated files
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
@@ -60,6 +82,7 @@ func DefaultConfig() Config {
// New creates a new logger with config
func New(cfg Config) *Logger {
var writers []io.Writer
var closers []io.Closer
// Console output with color
if cfg.Console {
@@ -87,6 +110,7 @@ func New(cfg Config) *Logger {
LocalTime: true,
}
writers = append(writers, fileWriter)
closers = append(closers, fileWriter)
}
// Combine writers
@@ -106,14 +130,78 @@ func New(cfg Config) *Logger {
Timestamp().
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
func (l *Logger) WithPrefix(prefix string) *Logger {
return &Logger{
zl: l.zl.With().Str("module", prefix).Logger(),
n := &Logger{zl: l.zl.With().Str("module", prefix).Logger(), auditPath: l.auditPath}
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
@@ -151,21 +239,102 @@ func (l *Logger) GetLevel() Level {
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) {
e := l.zl.Info().
Str("event", event).
Uint64("client_id", clientID).
Str("ip", ip).
Time("time", time.Now())
if len(extra) >= 2 {
for i := 0; i+1 < len(extra); i += 2 {
e = e.Str(extra[i], extra[i+1])
}
// clientID is the TCP connection id; the device's stable YAMA id arrives
// as the "clientID" extra and is normalized to device_id so every audit
// record uses the same field names (conn / device_id).
fields := map[string]any{
"conn": clientID,
"ip": ip,
}
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
+156
View File
@@ -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"])
}
}
+1
View File
@@ -110,6 +110,7 @@ const (
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"
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]
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
+171
View File
@@ -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[:])
}
+328
View File
@@ -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
}
+294
View File
@@ -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)
}
}
+195
View File
@@ -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)
}
}
+131
View File
@@ -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")
}
}
+53
View File
@@ -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
View File
@@ -16,6 +16,7 @@ import (
"unicode"
"unicode/utf8"
"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/wsauth"
@@ -30,6 +31,7 @@ type Server struct {
srv *http.Server
hub *hub.Hub
auth *wsauth.Authenticator
history *devicehistory.Store // nil → no offline devices
ws *wsHub
allowedOrigins []string // for WS Origin allowlist; empty = same-origin only
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}
}
// 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
// chainable setup. Safe to call before Start; ignored thereafter.
func (s *Server) WithConfig(cfg Config) *Server {
@@ -107,7 +117,7 @@ func (s *Server) Start() error {
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).
withLoginRateLimiters(s.loginIPLimit, s.loginUserLimit).
withTrustForwardedFor(s.trustForwardedFor)
+23 -2
View File
@@ -33,26 +33,47 @@ func (s *Server) handleSSO(w http.ResponseWriter, r *http.Request) {
}
username, role, ok := s.sso.Detect(r)
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)
return
}
// 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
// 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" {
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)
return
}
token, err := s.auth.IssueToken(username, role)
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.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"internal error"}`))
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("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]string{
+130 -17
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/gorilla/websocket"
"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/wsauth"
@@ -47,11 +48,19 @@ type wsClient struct {
closed chan struct{}
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).
nonce string // outstanding challenge — cleared after a successful login
token string // set once authenticated
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
// watchIDs is the set of devices this browser is streaming (the screen
@@ -59,9 +68,30 @@ type wsClient struct {
// Guarded by wsHub.mu.
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.
// Unix seconds, guarded by wsHub.mu.
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
@@ -96,7 +126,7 @@ func (c *wsClient) close() {
// challengeCaps advertises optional protocol features. It is appended to every
// challenge, including the rotation after a failed login, because the browser
// 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
// 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 {
auth *wsauth.Authenticator
devices *hub.Hub
history *devicehistory.Store // nil → device_list stays online-only
log *logger.Logger
mu sync.RWMutex
@@ -138,10 +169,11 @@ type wsHub struct {
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{
auth: auth,
devices: devices,
history: history,
log: log,
clients: make(map[*wsClient]struct{}),
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
// same with its local refcount. Without this, a stale entry would keep
// countWatchers non-zero and later block the screen teardown.
type viewer struct {
c *wsClient
start time.Time
}
var stopped []viewer
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients {
if !c.isWatching(id) {
continue
@@ -294,6 +330,19 @@ func (h *wsHub) OnDeviceOffline(id string) {
if c.token != "" {
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,
"reason": reason,
})
type closed struct {
c *wsClient
start time.Time
why string
}
var done []closed
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
why := c.termCloseReason
if why == "" {
why = reason
}
done = append(done, closed{c, c.termStartedAt, why})
c.termWatching = ""
c.termStartedAt = time.Time{}
c.termCloseReason = ""
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
@@ -464,26 +534,49 @@ func (h *wsHub) unregister(c *wsClient) {
// below must run OUTSIDE the lock: countWatchers takes h.mu.RLock and
// deferredCloseScreen takes h.mu.Lock.
watched := make([]string, 0, len(c.watchIDs))
started := make(map[string]time.Time, len(c.watchIDs))
for id := range c.watchIDs {
watched = append(watched, id)
started[id] = c.watchStartedAt[id]
}
c.watchIDs = nil
c.watchStartedAt = nil
c.keyframeAt = nil
termDevice := c.termWatching
termStart := c.termStartedAt
c.termWatching = ""
c.termStartedAt = time.Time{}
c.termCloseReason = ""
h.mu.Unlock()
// If this client was the last viewer of a device, tear down the screen
// session so the device stops encoding.
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 {
h.deferredCloseScreen(id)
}
}
// Terminal sessions are single-viewer by design, so any open session
// belongs to this client. Tear it down so the next viewer doesn't
// hit ErrTerminalBusy from an abandoned session.
if c.termWatching != "" {
h.devices.CloseTerminalSession(c.termWatching)
c.termWatching = ""
// hit ErrTerminalBusy from an abandoned session. The client is already
// out of h.clients, so OnTerminalClosed won't reach it — audit here.
if termDevice != "" {
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.
// Frontend may close+reopen the WS at any time (visibilitychange handler,
// 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 -------------------------------------------------------
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
// allowlist instead of a package-level mutable.
upgrader := baseUpgrader
@@ -525,7 +628,10 @@ func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, 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
}
conn.SetReadLimit(wsReadLimit)
@@ -538,15 +644,22 @@ func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
}
client := &wsClient{
conn: conn,
send: make(chan wsMsg, wsSendBuffer),
closed: make(chan struct{}),
nonce: nonce,
addr: clientIP(r, h.trustForwardedFor),
watchIDs: make(map[string]struct{}),
keyframeAt: make(map[string]int64),
conn: conn,
send: make(chan wsMsg, wsSendBuffer),
closed: make(chan struct{}),
id: newConnID(),
addr: clientIP(r, h.trustForwardedFor),
peerAddr: stripPort(r.RemoteAddr),
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.audit(client, false, auditWSConnect, client.wsRequestFields())
defer h.unregister(client)
go h.writeLoop(client)
+388 -20
View File
@@ -2,7 +2,9 @@ package web
import (
"encoding/json"
"errors"
"sort"
"strings"
"time"
"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 7 covers admin: create_user / delete_user / list_users / get_groups.
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 {
case "get_salt":
h.handleGetSalt(c, raw)
@@ -82,6 +87,8 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
h.handleTermClose(c, raw)
// Admin operations (Phase 7).
case "delete_device":
h.handleDeleteDevice(c, raw)
case "create_user":
h.handleCreateUser(c, raw)
case "delete_user":
@@ -90,6 +97,19 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
h.handleListUsers(c, raw)
case "get_groups":
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.
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{
"cmd": replyCmd, "ok": false, "msg": "Permission denied",
}))
@@ -130,7 +155,21 @@ func (h *wsHub) handleGetSalt(c *wsClient, raw []byte) {
}
_ = 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
// unknown users — same shape as a real salt — so an attacker can't
// 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"`
}
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"}))
return
}
@@ -158,7 +199,11 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
// known account (admin). Either dimension tripping rejects the call
// with a uniform "credentials" error so the limit is not detectable.
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)
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"}))
// 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
// replays from a different connection can't reuse a captured response.
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"}))
h.rotateChallenge(c)
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)
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
// even within the rate-limit budget, and erases the timing
// 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.
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.token = token
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{
"cmd": "login_result",
@@ -254,6 +324,7 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) {
// Take h.mu so event-handler readers (OnResolutionChange/OnScreenFrame)
// get a consistent view of the watch set.
var closed []string
starts := make(map[string]time.Time)
h.mu.Lock()
if in.MultiWatch && in.ID != "" {
// 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) {
delete(c.watchIDs, in.ID)
delete(c.keyframeAt, in.ID)
starts[in.ID] = c.watchStartedAt[in.ID]
delete(c.watchStartedAt, in.ID)
closed = append(closed, in.ID)
}
} else {
@@ -268,9 +341,11 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) {
// one device, so clear everything.
for id := range c.watchIDs {
closed = append(closed, id)
starts[id] = c.watchStartedAt[id]
}
c.watchIDs = make(map[string]struct{})
c.keyframeAt = make(map[string]int64)
c.watchStartedAt = make(map[string]time.Time)
}
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.
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 {
h.deferredCloseScreen(id)
}
@@ -390,8 +472,10 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
}
var stopped []string
var tooMany bool
starts := make(map[string]time.Time)
var tooMany, already bool
h.mu.Lock()
_, already = c.watchIDs[in.ID]
stopped, tooMany = applyConnectWatch(c, in.ID, in.MultiWatch)
// 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.
@@ -403,10 +487,24 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
t.Stop()
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()
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")))
return
}
@@ -414,6 +512,13 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
// screen sessions are torn down outside the lock; deferredCloseScreen is
// a no-op while another browser still watches the device.
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 {
h.deferredCloseScreen(id)
}
@@ -426,6 +531,15 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
// next IDR (~15 s).
cache := h.devices.ScreenState(in.ID)
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["width"] = cache.Width
m["height"] = cache.Height
@@ -458,16 +572,30 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
h.mu.Lock()
delete(c.watchIDs, in.ID)
delete(c.keyframeAt, in.ID)
delete(c.watchStartedAt, in.ID)
h.mu.Unlock()
msg := "Device offline"
reason := "device_offline"
if err != hub.ErrDeviceOffline {
msg = "Failed to start screen capture"
reason = "start_failed"
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)))
return
}
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, "")))
}
@@ -558,7 +686,10 @@ func (h *wsHub) handleRdpReset(c *wsClient, raw []byte) {
}
deviceID := h.resolveWatchDevice(c, raw)
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
}
// 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
// main conn would silently no-op.
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
}
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) {
if !h.requireAuth(c, raw, "device_list") {
return
}
devices := h.devices.ListDevices()
c.queue(mustJSON(map[string]any{
"cmd": "device_list",
"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
// session store (not against c.token). Tokens live independently of WS
// 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)
if in.Token == "" {
h.auditBadToken(c, replyCmd, "missing_token")
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
return false
}
sess, err := h.auth.ValidateToken(in.Token)
if err != nil {
h.auditBadToken(c, replyCmd, "invalid_token")
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
return false
}
resumed := false
h.mu.Lock()
if c.token == "" {
c.token = in.Token
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
}
// 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
// screen sub-connection as a COMMAND_SCREEN_CONTROL packet carrying a
// 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)
if deviceID == "" {
h.audit(c, true, auditClipSet, map[string]any{
"ok": false,
"reason": "no_device",
})
return
}
gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(in.Text))
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
}
payload := append([]byte{protocol.CommandScreenSetClipboard}, gbk...)
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
}
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
@@ -802,12 +1001,25 @@ func (h *wsHub) handleClipboardGet(c *wsClient, raw []byte) {
}
deviceID := h.resolveWatchDevice(c, raw)
if deviceID == "" {
h.audit(c, true, auditClipGet, map[string]any{
"ok": false,
"reason": "no_device",
})
return
}
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
}
h.audit(c, false, auditClipGet, map[string]any{
"device_id": deviceID,
"ok": true,
})
}
// 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"`
}
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"}))
return
}
@@ -833,14 +1049,116 @@ func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) {
in.Role = "viewer"
}
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()}))
return
}
h.log.Info("user created by %s: name=%s role=%s groups=%d",
c.role, in.Username, in.Role, len(in.AllowedGroups))
h.audit(c, false, auditUserCreate, map[string]any{
"target": in.Username,
"target_role": in.Role,
"groups": len(in.AllowedGroups),
"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.
// Admin-only. The bootstrap "admin" account is rejected at the wsauth layer.
func (h *wsHub) handleDeleteUser(c *wsClient, raw []byte) {
@@ -852,14 +1170,27 @@ func (h *wsHub) handleDeleteUser(c *wsClient, raw []byte) {
Username string `json:"username"`
}
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"}))
return
}
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()}))
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}))
}
@@ -949,34 +1280,57 @@ func (h *wsHub) handleTermOpen(c *wsClient, raw []byte) {
// screen path in handleConnect.
h.mu.Lock()
if c.termWatching != "" && c.termWatching != in.ID {
active := c.termWatching
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{
"cmd": replyCmd, "ok": false,
"msg": "Close current terminal before opening another",
}))
return
}
newSession := c.termWatching != in.ID
c.termWatching = in.ID
c.termCloseReason = ""
if newSession || c.termStartedAt.IsZero() {
c.termStartedAt = time.Now()
}
h.mu.Unlock()
if err := h.devices.OpenTerminalSession(in.ID); err != nil {
h.mu.Lock()
c.termWatching = ""
c.termStartedAt = time.Time{}
h.mu.Unlock()
msg := "Device offline"
reason := "device_offline"
switch err {
case hub.ErrTerminalBusy:
msg = "Terminal already open by another viewer"
reason = "terminal_busy"
case hub.ErrDeviceOffline:
msg = "Device offline"
default:
msg = "Failed to start terminal"
reason = "start_failed"
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}))
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
@@ -997,7 +1351,10 @@ func (h *wsHub) handleTermInput(c *wsClient, raw []byte) {
if in.ID == "" || in.Data == "" {
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
}
_ = 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 {
return
}
if c.termWatching != in.ID {
h.mu.RLock()
watching := c.termWatching == in.ID
h.mu.RUnlock()
if !watching {
return
}
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 == "" {
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
}
h.devices.CloseTerminalSession(in.ID)
+802 -51
View File
File diff suppressed because it is too large. Load diff