15 Commits
Author SHA1 Message Date
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
yuanyuanxianganddeepseek-flash 1922f87dd2 Feature: Multi-device screen wall on the Go server
Bring the Go web backend up to parity with the MFC server's screen wall.
A browser tab can now stream several devices over one WebSocket: the
single `watching` field becomes a per-connection watch set guarded by the
hub mutex, capped at 32 devices per socket. The challenge advertises
`caps.multiWatch` and `caps.keyframeRequest`, binary frames stay keyed by
the low 32 bits of the device ID, and connect/disconnect replies echo the
device id and request id so the wall can route concurrent responses.

Input commands (mouse, key, clipboard, rdp_reset) resolve their target
from the watch set: an explicit id must be a current subscription, and an
absent id is accepted only while exactly one device is watched. Legacy
frontends that do not send `multiWatch` keep replace-all semantics, so a
repeated device switch cannot grow the set without bound. Offline
devices are removed from every watch set, and teardown is computed from a
snapshot taken outside the hub lock to avoid re-entrant locking.

Add the `keyframe` command: it replays the cached IDR, then asks the
device to emit a fresh one, rate-limited to one request per device per
second. This exposed a Go-only corruption bug: the client answers the
request with TOKEN_KEYFRAME (134), which the dispatcher treated as a
no-op. That token only appears on forced IDRs because the client's GOP is
effectively infinite, so the dropped IDR left the browser decoding
P-frames against a reference chain it never received, producing
persistent garbling. Relay it as a keyframe instead. Also drop the
cached IDR when the resolution changes, mirroring the C++ server, so a
late joiner cannot receive an old-geometry keyframe.

Add tests for the watch-set transitions and for relaying TOKEN_KEYFRAME.

Co-Authored-By: deepseek-flash
2026-10-18 11:51:28 +02:00
yuanyuanxianganddeepseek-flash 8eab26c1f6 Feature: Web screen wall for multi-device live monitoring
Add a third view to the web UI, the screen wall, which shows several
devices at once in one tab with 1x1 / 2x2 / 3x3 / 4x4 / 5x5 layouts and a
device sidebar on the left. Tiles are monitoring-only; clicking one
enlarges that device into the existing single-screen page for control, and
Back returns to the wall. The same device can be added several times: all
tiles of one device share a single decoder and a single stream.

The wall decodes at most 9 streams on desktop and 4 on touch devices.
Tiles beyond the budget keep receiving frames but have their decoder
closed and show the last frame plus a Paused badge; they are promoted
automatically when a live tile goes away. Subscriptions are reference
counted on the client (streams map with owner sets), so a stream is only
connected on the 0 -> 1 transition and disconnected on 1 -> 0, and the
wall and the single-screen page can share one device without tearing the
stream down.

Server side, WebClient now holds a set of watched devices instead of a
single id. A new multiWatch command flag selects set semantics, while
clients without it keep the old replace-all behaviour, so an old frontend
against the new server is unchanged. connect and disconnect responses echo
id and req so the frontend can route them, input commands carry an id and
are resolved against the watch set (dropped when the set has several
devices and no id), and the per-client watch set is capped at 32.

For fast recovery after a pause or when joining late, the server caches
each device's raw IDR payload and replays it on connect and on a new
keyframe command, then asks the client to force a new IDR. The client gets
COMMAND_SCREEN_KEYFRAME_REQ = 169 and an atomic request flag that is only
cleared after an IDR actually came out of the encoder, so a failed capture
or EAGAIN does not lose the request. The frontend retries the keyframe
request up to three times; an old client that ignores the command simply
recovers on the next natural GOP.

Also fix the keyframe cache double wrapping: BroadcastH264Frame used to
cache the full packet header and HandleConnect wrapped it again. The cache
now stores data + 9, len - 9 and every sender wraps it through
BuildFramePacket, and the cache is cleared on cold start and on resolution
change. StopRemoteDesktop is always called outside m_ClientsMutex, with
the ids collected under the lock first.

Co-Authored-By: deepseek-flash
2026-10-18 11:03:17 +02:00
yuanyuanxianganddeepseek-v4-pro 04f4eb9947 Fix: share web login session across browser tabs
The login token and role were stored in sessionStorage, which is
isolated per tab, so opening a second tab forced a fresh password
login. Switch every token/role read and write to localStorage so all
tabs on the same origin share the session and a new tab auto-resumes
it on load. Expiry (device_list ok:false) and explicit logout still
clear it as before.

Co-Authored-By: deepseek-v4-pro
2026-10-17 22:44:50 +02:00
yuanyuanxianganddeepseek-v4-pro 5cea1167be Feature: Web remote desktop phone IME input box ("I")
Add an "I" button to the mobile shortcut bar that opens a chat-style
input box pinned above the virtual keyboard. Text is delivered to the
remote through the existing clipboard bridge (clipboard_set + synthetic
Ctrl+V); an empty box turns Enter/Backspace into remote Enter/Backspace
so the phone can submit and edit text in the focused window. The box
soft-wraps and auto-grows up to four lines.

The box is a <textarea>, so the desktop key-forwarding handlers now skip
TEXTAREA targets as well, which stops an empty-box Backspace from being
forwarded twice (once by imeBackspace, once by the document handler).

Co-Authored-By: deepseek-v4-pro
2026-10-17 22:06:46 +02:00
yuanyuanxianganddeepseek-v4-pro 0c04f7516b Feature: Web remote desktop bidirectional text clipboard sync
Add keyboard-shortcut (Ctrl+C / Ctrl+X / Ctrl+V) text clipboard transfer
between the browser and the remote desktop, reusing the clipboard protocol
that already exists in the native viewer (COMMAND_SCREEN_GET_CLIPBOARD=24,
COMMAND_SCREEN_SET_CLIPBOARD=25, TOKEN_CLIPBOARD_TEXT=118) so the client
needs no change.

Local -> remote: pressing Ctrl+V reads the browser clipboard, pushes it to
the remote clipboard (UTF-8 -> GBK, matching the client's CF_TEXT/ANSI
handling) and then synthesizes a remote Ctrl+V so the pasted text is the
newly delivered one.

Remote -> local: Ctrl+C or Ctrl+X is forwarded to the remote as usual and,
after a short delay, the remote clipboard is pulled back and written to the
browser clipboard for a later local Ctrl+V.

Both the MFC and Go servers get the same three clipboard signaling paths
(clipboard_set, clipboard_get, clipboard_text) so the shared single-page
frontend behaves identically on either backend.

Co-Authored-By: deepseek-v4-pro
2026-10-17 19:52:38 +02:00
yuanyuanxianganddeepseek-v4-pro e6b4fe3a85 Fix: reissue client_id on clone collision and harden V1 file-transfer command
撞 ID 的克隆机(同镜像 MachineGuid 相同导致 client_id 相同)此前会被服务端暂存为
替补、永远无法转正,从而不显示在主机列表。现在服务端检测到「client_id 相同但主机名
不同」时,主动下发 COMMAND_REGEN_CLIENT_ID,客户端在旧 ID 基础上掺随机 UUID 重算后
重连,以新 ID 正常上线;触发条件仅看主机名,排除 IP 漂移与纯用户名变化,避免误伤
正常重连的机器。

同时加固 V1 服务端→客户端文件传输:COMMAND_GET_FILE 指令的一次性 md5 令牌绑定登记
时的客户端源 IP(PutCmd/PopCmd 均带 peerIP),防止异常客户端冒用;并对指令包增加边界
校验(len 与 NUL 结尾),杜绝无 NUL 包导致的越界读。

Co-Authored-By: deepseek-v4-pro
2026-10-17 09:29:33 +02:00
yuanyuanxianganddeepseek-v4-pro d3a339247b Fix: reconnect after server restart without reinstalling the loader
The S_SERVER_EXIT respawn fix previously lived in test.cpp, which a
deployed TestRun.exe cannot pick up without reinstalling. Move the
reconnect decision into ClientDll::IsStoped(): when the master instance
is in S_SERVER_EXIT, report reconnectable immediately instead of waiting
for live shared instances to drain, so the already-deployed loader
reconnects against a hot-loaded DLL.

Shared instances stay connected to their own servers, preserving the
IsSharedRunning contract that the master's state change must not tear
them down.

Also guard the RUNNING_PARALLEL branch so re-entering StartClient on
reconnect spawns parallel instances once instead of duplicating them.

Co-Authored-By: deepseek-v4-pro
2026-10-16 21:36:52 +02:00
yuanyuanxianganddeepseek-v4-pro 33986f4893 Fix: shared-host respawn block and web login brand placeholder
Two unrelated fixes bundled here.

1. Client respawn (ClientDll.cpp EasyRun/Run, test.cpp Run): after a host
   is shared or transferred to another server, restarting the master
   (server A) left the host's main instance stuck, because the respawn
   loop waited on IsStoped() (g_bThreadExit && GetCount()==0), which never
   becomes true while a still-alive shared instance holds AddCount(1).
   S_SERVER_EXIT now triggers an immediate reconnect without waiting for
   shared instances; client-exit / update still waits for all instances to
   drain. Verified by 100 restart cycles.

2. Web login page (WebService.cpp): the MFC web server only substituted
   __BASE_PATH__ in index.html and left __BRAND_NAME__ as a literal token
   in the login page title/heading. Substitute it with "SimpleRemoter" to
   match the original branding and the Go server's default.

Co-Authored-By: deepseek-v4-pro
2026-10-16 21:08:42 +02:00
yuanyuanxianganddeepseek-v4-pro e5f5720867 Fix: Add missing FileManagerDlg translations to language files
FileManagerDlg.cpp still had several user-facing Chinese strings with no
entry in en_US/zh_TW/de_DE: the "invalid file handle", "no files
selected", "V2 transfer started", "V2 download request sent" and "main
connection not found" messages. Add translations for all five.

The "main connection not found" string contained a literal '=', which
CIniParser treats as the key/value delimiter, so it could never match as
a translation key. Reword it to "clientID %llu" (no '=') so the key
round-trips. The two Mprintf log lines on the V2 checksum path are
log-only and intentionally left untranslated.

Co-Authored-By: deepseek-v4-pro
2026-10-16 13:54:00 +02:00
yuanyuanxianganddeepseek-v4-pro d7b49a3d06 Feat: Add whitelist action to log-list right-click menu
Adds a "白名单" item after "解除封禁" in the message-log context menu.
It extracts the IP from each selected log entry, asks for confirmation,
then adds it to the shared IPWhitelist singleton — which IOCPServer and
the DLL rate limiter already consult live — so the entry takes effect on
new connections immediately, without a restart. The whitelist is
persisted to settings\IPWhitelist the same way the Network dialog does.

- An IP already in the whitelist is reported as such and skipped.
- An IP in the IP blacklist (banned) is reported as banned and skipped,
  rather than silently removed from the blacklist.
- Menu item, confirmation, and result strings are added to the
  en_US/zh_TW/de_DE language files.

Co-Authored-By: deepseek-v4-pro
2026-10-16 13:16:33 +02:00
yuanyuanxianganddeepseek-v4-pro c85039ea0a Fix: Online-host list column-toggle leaves ghost artifact after last column
CListCtrlEx::OnEraseBkgnd (with m_bSkipEraseBkgnd) only erases the empty
area below the last item, not the horizontal strip right of the last
column (the ~20px scrollbar reserve AdjustColumnWidths always leaves).
Changing the column count via SetColumnVisible/ToggleColumnVisibility
redistributed widths but never repainted with background erase, so stale
pixels from the previous last column lingered next to the new one (e.g.
after "TLS") until a tab switch or minimize/restore forced a full redraw.

Mirror the proven OnSize needRefresh path: temporarily lift the
skip-erase flag and issue a full RedrawWindow with
RDW_ERASE|RDW_INVALIDATE|RDW_ALLCHILDREN|RDW_UPDATENOW after each
visibility change.

Co-Authored-By: deepseek-v4-pro
2026-10-16 13:03:05 +02:00
yuanyuanxianganddeepseek-v4-pro a447452521 Fix: Silence SOCKET_DLLLOADER log flood and tighten device-port logging
SOCKET_DLLLOADER (210) is the shellcode / memory-DLL loader asking the
server for the full DLL payload before it can load the client. The MFC
master console serves it via SendServerDll; the Go web device port has no
payload to hand out, so every memory-DLL client startup/reconnect fell
into the default dispatch branch and logged "Unhandled command 210",
flooding the log. Add a protocol constant and drop the command silently.

While auditing the device-port logging surface:

- Downgrade wrapTLS's per-connection hex-dump lines and the screen-spy
  "[timing]" cold-start line from Info to Debug (they fire once per TCP
  connection / screen view and are diagnostics, not operational events).

- Distinguish a sub-connection hijack attempt from a plain orphan: when a
  screen/terminal sub-conn claims a MasterID from a source IP other than
  the device's login conn, log a dedicated Warn instead of mislabeling it
  as "orphan ... unknown device".

Co-Authored-By: deepseek-v4-pro
2026-10-16 12:26:35 +02:00
43 changed files with 7005 additions and 394 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/
+24 -14
View File
@@ -445,6 +445,11 @@ extern "C" __declspec(dllexport) void StopRun()
// 是否成功停止
extern "C" __declspec(dllexport) bool IsStoped()
{
// 主控端重启(S_SERVER_EXIT):主实例工作线程已结束,即使分享实例仍在运行,
// 也视为「可重连」。旧 TestRun 加载循环靠它立即重连,无需卸载/重装 TestRun.exe;
// 分享实例继续存活,不受主控端重启影响。
if (S_SERVER_EXIT == g_MyApp.g_bExit)
return true;
return g_MyApp.g_bThreadExit && ClientApp::GetCount() == 0;
}
@@ -462,14 +467,12 @@ extern "C" __declspec(dllexport) int EasyRun()
do {
TestRun((char*)settings.ServerIP(), settings.ServerPort());
if (S_SERVER_EXIT == app.g_bExit)
continue; // 主控端重启:立即重连,不等仍存活的分享实例
// 受控端退出 / 程序更新:等所有实例(含分享实例)退干净再退出
while (!IsStoped())
Sleep(50);
if (S_CLIENT_EXIT == app.g_bExit) // 受控端退出
break;
else if (S_SERVER_EXIT == app.g_bExit)
continue;
else // S_CLIENT_UPDATE: 程序更新
break;
break;
} while (true);
return app.g_bExit;
@@ -555,14 +558,14 @@ extern "C" __declspec(dllexport) void Run(HWND hwnd, HINSTANCE hinst, LPSTR lpsz
do {
TestRun((char*)result[0].c_str(), atoi(result[1].c_str()));
if (bExit == S_SERVER_EXIT)
continue; // 主控端重启:立即重连,不等仍存活的分享实例
// 受控端退出 / 程序更新:等所有实例(含分享实例)退干净
while (!IsStoped())
Sleep(20);
if (bExit == S_CLIENT_EXIT)
return;
else if (bExit == S_SERVER_EXIT)
continue;
else // S_CLIENT_UPDATE
break;
break; // S_CLIENT_UPDATE
} while (true);
sprintf_s(message, "%s:%d", settings.ServerIP(), settings.ServerPort());
@@ -608,10 +611,17 @@ DWORD WINAPI StartClient(LPVOID lParam)
}
auto list = app.GetSharedMasterList();
if (list.size() > 1 && settings.runningType == RUNNING_PARALLEL) {
for (int i=1; i<list.size(); ++i) {
std::string addr = list[i] + ":" + std::to_string(settings.ServerPort());
auto a = NewClientStartArg(addr.c_str(), IsSharedRunning, FALSE);
if (nullptr != a) CloseHandle(__CreateThread(0, 0, StartClientApp, a, 0, 0));
// 并发上线的并行实例只派生一次:主控端重启重连时会再次进入 StartClient,
// 若不守护会重复派生并行连接。主实例的 SetServer(list[0]) 保持每次执行,
// 让主实例始终指向域名解析出的第一个 IP。
static bool parallelHasRun = false;
if (!parallelHasRun) {
parallelHasRun = true;
for (int i=1; i<list.size(); ++i) {
std::string addr = list[i] + ":" + std::to_string(settings.ServerPort());
auto a = NewClientStartArg(addr.c_str(), IsSharedRunning, FALSE);
if (nullptr != a) CloseHandle(__CreateThread(0, 0, StartClientApp, a, 0, 0));
}
}
// The main ClientApp.
settings.SetServer(list[0].c_str(), settings.ServerPort());
+12
View File
@@ -838,6 +838,18 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
m_cfg->SetStr("settings", "group_name", group);
break;
}
case COMMAND_REGEN_CLIENT_ID: {
// 撞 ID 的克隆机:服务端已判定「主机名不同却撞 client_id」,让本机重生成 ID。
// 新 ID = 旧 ID + 随机 UUID 再哈希,写回注册表后主动断开主连接,重连循环
// 会用新 ID 重新登录,脱离撞 ID 状态(脱离替补、正常显示在主机列表)。
uint64_t newID = RegenerateClientID(m_conn->clientID);
m_cfg->SetStr("settings", "client_id", std::to_string(newID));
Mprintf("[regen-client-id] 重生成 client_id: %llu -> %llu,即将重连\n",
m_conn->clientID, newID);
if (m_ClientObject)
m_ClientObject->Disconnect();
break;
}
case COMMAND_TLS_ENABLE: {
// 服务端下发信任锚证书:payload = PEM 正文(szBuffer+1 .. ulLength-1)。
+29
View File
@@ -289,6 +289,35 @@ uint64_t CalcalateIDv2(const std::string& machineGuid, const std::string& normal
return XXH64(s.c_str(), s.length(), 0);
}
// 撞 ID 的克隆机重生成 client_id:在旧 ID 基础上掺一个一次性随机 UUID 再哈希。
// 只在服务端判定「主机名不同却撞 ID」后下发 COMMAND_REGEN_CLIENT_ID 触发,不影响
// 正常(首次)登录的 CalcalateIDv2 算法;唯一性由随机 UUID 保证,同批克隆机各自
// 随机,重算后必然不同。
uint64_t RegenerateClientID(uint64_t oldID)
{
// 16 字节随机数充当一次性 UUID(RtlGenRandom = advapi32 SystemFunction036)。
unsigned char rnd[16] = {};
if (!RtlGenRandom(rnd, (ULONG)sizeof(rnd))) {
// 极少数环境 RtlGenRandom 可能失败:退化为时间/进程/线程/性能计数混合的 LCG,
// 保证仍能产出与旧 ID 不同的新 ID(不追求密码学强度,只需撞车概率足够低)。
uint64_t mix = GetTickCount64() ^ ((uint64_t)GetCurrentProcessId() << 32)
^ (uint64_t)GetCurrentThreadId();
LARGE_INTEGER pc;
QueryPerformanceCounter(&pc);
mix ^= (uint64_t)pc.QuadPart;
for (int i = 0; i < 16; ++i) {
mix = mix * 6364136223846793005ULL + 1442695040888963407ULL;
rnd[i] = (unsigned char)(mix >> 56);
}
}
char hex[33];
for (int i = 0; i < 16; ++i)
sprintf_s(hex + i * 2, 3, "%02X", (unsigned)rnd[i]);
hex[32] = '\0';
std::string seed = std::to_string(oldID) + "|" + hex;
return XXH64(seed.c_str(), seed.size(), 0);
}
BOOL IsAuthKernel(std::string &str) {
BOOL isAuthKernel = FALSE;
std::string pid = std::to_string(GetCurrentProcessId());
+1
View File
@@ -7,6 +7,7 @@
std::string GetMachineGuidWindows();
uint64_t CalcalateIDv2(const std::string& machineGuid, const std::string& normalizedPath, bool isAuth = false);
uint64_t RegenerateClientID(uint64_t oldID);
BOOL IsAuthKernel(std::string& str);
LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS &conn, const std::string& expiredDate, bool isAuth);
DWORD CPUClockMHz();
+17 -1
View File
@@ -14,6 +14,7 @@
#include <functional>
#include <future>
#include <memory>
#include <atomic>
#include <emmintrin.h> // SSE2
#include "common/config.h"
#include "VideoEncoderBase.h"
@@ -149,6 +150,9 @@ public:
int m_FrameID; // 帧序号
int m_GOP; // 关键帧间隔
bool m_SendKeyFrame; // 发送关键帧
// Web 端请求下一帧强制 IDR(COMMAND_SCREEN_KEYFRAME_REQ)。只对 H.264 生效;
// 必须在成功编码出 IDR 后清除,否则 ScanNextScreen 失败/硬编 EAGAIN 时请求会丢。
std::atomic<bool> m_bForceKeyFrame{false};
std::unique_ptr<VideoEncoderBase> m_encoder; // 编码器,ensureEncoder() lazy 创建,走 EncoderFactory 探测
bool m_bEncoderPrimed = false; // encoder 是否已成功产出过一个包;
// false 时禁止 skip——避免单显示器路径
@@ -257,6 +261,11 @@ public:
m_BlockBuffers[blockY] = new BYTE[m_ulFullWidth * m_ulFullHeight * 4 * 2 / m_BlockNum + 12];
}
}
// 请求下一帧强制 IDR(可跨线程调用)。只影响 H.264 编码路径;
// 实际清除发生在成功编码出 IDR 之后。
void RequestKeyFrame() { m_bForceKeyFrame.store(true, std::memory_order_relaxed); }
virtual ~ScreenCapture()
{
if (m_BitmapInfor_Full != NULL) {
@@ -876,7 +885,12 @@ public:
{
BYTE algo = m_bAlgorithm;
int frameID = m_FrameID + 1;
bool keyFrame = (frameID % m_GOP == 0);
// Web 端可请求强制 IDR(COMMAND_SCREEN_KEYFRAME_REQ),只对 H.264 有意义。
// 标志在成功编码出 IDR 后才清除(见下方 H.264 keyframe 分支),
// 保证 ScanNextScreen 失败或硬编 EAGAIN 时请求不会丢失。
bool forceKeyFrame = (algo == ALGORITHM_H264) &&
m_bForceKeyFrame.load(std::memory_order_relaxed);
bool keyFrame = forceKeyFrame || (frameID % m_GOP == 0);
m_RectBuffer[0] = keyFrame ? TOKEN_KEYFRAME : TOKEN_NEXTSCREEN;
LPBYTE data = m_RectBuffer + 1;
// 写入使用了哪种算法
@@ -967,6 +981,8 @@ public:
*ulNextSendLength = 1 + offset + encoded_size;
memcpy(data + offset, encoded_data, encoded_size);
m_bEncoderPrimed = true; // 与下方 FirstBuffer 同步:自此 skip 安全
// IDR 已成功产出,满足(可能的)强制关键帧请求。
m_bForceKeyFrame.store(false, std::memory_order_relaxed);
break;
}
default:
+7
View File
@@ -930,6 +930,13 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
SwitchScreen();
break;
}
case COMMAND_SCREEN_KEYFRAME_REQ: {
// Web 屏幕墙恢复/新加入者请求:下一帧强制 IDR,立即重建参考链。
if (m_ScreenSpyObject) {
m_ScreenSpyObject->RequestKeyFrame();
}
break;
}
case CMD_FULL_SCREEN: {
int fullScreen = szBuffer[1];
iniFile cfg(CLIENT_PATH);
+6 -2
View File
@@ -495,8 +495,12 @@ BOOL Run(const char* argv1, int argv2, const std::string &runCmd)
}
do {
run(ip, port);
while (bStop && !bStop() && 0 == status)
Sleep(20);
if (bExit && bExit() != S_SERVER_EXIT) {
// 受控端退出 / 程序更新:等所有实例(含分享实例)退干净,避免下方 FreeLibrary
// 卸载 DLL 时残留分享实例线程。主控端重启(S_SERVER_EXIT)则立即重连,不等仍存活的分享实例。
while (bStop && !bStop() && 0 == status)
Sleep(20);
}
} while (bExit && !bExit() && 0 == status);
while (bStop && !bStop() && 1 == status)
+2
View File
@@ -327,6 +327,8 @@ enum {
COMMAND_DRAWING_BOARD_PLUGIN = 165, // 服务端 → 客户端:打开远程画板(SimplePlugins 迁移)
COMMAND_TLS_ENABLE = 166, // 服务端 → 客户端:启用 TLS 传输(写注册表,下次启动生效)
COMMAND_TLS_DISABLE = 167, // 服务端 → 客户端:停用 TLS 传输(写注册表,下次启动生效)
COMMAND_REGEN_CLIENT_ID = 168, // 服务端 → 客户端:撞 ID 的克隆机重生成 client_id(掺随机 UUID)后重连
COMMAND_SCREEN_KEYFRAME_REQ = 169, // 服务端 → 客户端:下一次屏幕编码强制输出 IDR(Web 屏幕墙恢复用)
TOKEN_DECRYPT = 199,
TOKEN_REGEDIT = 200, // 注册表
+114 -7
View File
@@ -1036,6 +1036,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
ON_COMMAND(ID_MSGLOG_LOGIN_NOTIFY, &CMy2015RemoteDlg::OnMsglogLoginNotify)
ON_COMMAND(ID_MSGLOG_BAN_IP, &CMy2015RemoteDlg::OnMsglogBanIP)
ON_COMMAND(ID_MSGLOG_UNBAN_IP, &CMy2015RemoteDlg::OnMsglogUnbanIP)
ON_COMMAND(ID_MSGLOG_WHITELIST_IP, &CMy2015RemoteDlg::OnMsglogWhitelistIP)
ON_COMMAND(ID_ONLINE_ADD_WATCH, &CMy2015RemoteDlg::OnOnlineAddWatch)
ON_COMMAND(ID_ONLINE_LOGIN_NOTIFY, &CMy2015RemoteDlg::OnOnlineLoginNotify)
ON_NOTIFY(NM_CUSTOMDRAW, IDC_ONLINE, &CMy2015RemoteDlg::OnNMCustomdrawOnline)
@@ -1770,6 +1771,16 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
" 已在线: 主机名=%s IP=%s 用户名=%s\n",
id, strPCName.GetString(), data[ONLINELIST_IP].GetString(), newUser.GetString(),
oldName.GetString(), oldIP.GetString(), oldUser.GetString());
// 主机名不同 = 确定是不同物理机(克隆机撞 ID,而非同一台的 IP 漂移/重连)。
// 主动下发「重生成 client_id」命令:克隆机掺随机 UUID 重算后重连,以新 ID
// 重新上线脱离撞 ID 状态。纯 IP 不同、纯用户名不同不下发,避免误伤正常重连
// 的机器(其 ID 合法,重生成反而丢备注/授权/历史)。
if (strPCName.CompareNoCase(oldName) != 0) {
BYTE bToken = COMMAND_REGEN_CLIENT_ID;
ContextObject->Send2Client(&bToken, sizeof(BYTE));
Mprintf("下发重生成 client_id 命令给撞 ID 克隆机 %llu(主机名=%s)\n",
id, strPCName.GetString());
}
}
Mprintf("上线消息 - 主机重复上线,暂存替补 [2]: %llu. IP= %s. Path= %s\n", id, data[ONLINELIST_IP], path);
// 网络抖动导致客户端用新连接重复上线:这里不立即替换/关闭旧连接,
@@ -6107,15 +6118,33 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
}
case COMMAND_GET_FILE: {
// 发送文件
std::string dir = (char*)(szBuffer + 1);
char* ptr = (char*)szBuffer + 1 + dir.length() + 1;
auto md5 = CalcMD5FromBytes((BYTE*)ptr, len - 2 - dir.length());
if (!m_CmdList.PopCmd(md5)) {
// 边界校验:dir 必须在 len 内以 NUL 结尾,否则 std::string 越界读,且
// len - 2 - dir.length() 会下溢导致后续越界读。异常/恶意客户端可能构造无 NUL 的包。
if (len < 2) {
Mprintf("【警告】文件传输指令包过短: %u\n", len);
ContextObject->CancelIO();
break;
}
const char* pDir = (const char*)(szBuffer + 1);
const char* pDirEnd = (const char*)memchr(pDir, '\0', len - 1);
if (!pDirEnd) {
Mprintf("【警告】文件传输指令 dir 未以 NUL 结尾,丢弃\n");
ContextObject->CancelIO();
break;
}
size_t dirLen = pDirEnd - pDir;
size_t listLen = (len - 1) - dirLen - 1; // 文件列表字节数,已由 memchr 保证 >= 0
std::string dir(pDir, dirLen);
const char* ptr = pDirEnd + 1;
auto md5 = CalcMD5FromBytes((const BYTE*)ptr, (DWORD)listLen);
if (!m_CmdList.PopCmd(md5, ContextObject->GetPeerName())) {
Mprintf("【警告】文件传输指令非法或已过期: %s\n", md5.c_str());
ContextObject->CancelIO();
break;
}
auto files = *ptr ? ParseMultiStringPath(ptr, len - 2 - dir.length()) : std::vector<std::string> {};
// listLen==0 时 ptr 可能越过缓冲区末尾(空文件列表无独立结尾 NUL),先判 listLen>0
// 再解引用 *ptr,避免空列表时越界读 1 字节。
auto files = (listLen > 0 && *ptr) ? ParseMultiStringPath(ptr, listLen) : std::vector<std::string> {};
if (!files.empty()) {
CDlgFileSend* dlg = new CDlgFileSend(this, ContextObject->GetServer(), ContextObject, TRUE);
dlg->Create(IDD_DIALOG_FILESEND, GetDesktopWindow());
@@ -10236,14 +10265,16 @@ void CMy2015RemoteDlg::OnRClickMessage(NMHDR* pNMHDR, LRESULT* pResult)
menu.AppendMenu(MF_STRING, ID_MSGLOG_LOGIN_NOTIFY, _TR("上线提醒"));
menu.AppendMenu(MF_STRING, ID_MSGLOG_BAN_IP, _TR("封禁IP"));
menu.AppendMenu(MF_STRING, ID_MSGLOG_UNBAN_IP, _TR("解除封禁"));
menu.AppendMenu(MF_STRING, ID_MSGLOG_WHITELIST_IP, _TR("白名单"));
// 没有选中项时禁用"删除选中"、"复制选中"、"上线提醒"、"封禁IP"和"解除封禁"
// 没有选中项时禁用"删除选中"、"复制选中"、"上线提醒"、"封禁IP"、"解除封禁"和"白名单"
if (m_CList_Message.GetSelectedCount() == 0) {
menu.EnableMenuItem(ID_MSGLOG_DELETE, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_COPY, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_LOGIN_NOTIFY, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_BAN_IP, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_UNBAN_IP, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_WHITELIST_IP, MF_GRAYED);
}
// PowerShell不可用时禁用"上线提醒"
if (!GetNotifyManager().IsPowerShellAvailable()) {
@@ -10630,6 +10661,82 @@ void CMy2015RemoteDlg::OnMsglogUnbanIP()
MessageBoxL(details, _TR("解除封禁"), MB_ICONINFORMATION);
}
void CMy2015RemoteDlg::OnMsglogWhitelistIP()
{
// Get selected log messages and extract IP addresses from them
std::vector<std::string> ipsToAdd;
POSITION pos = m_CList_Message.GetFirstSelectedItemPosition();
while (pos) {
int row = m_CList_Message.GetNextSelectedItem(pos);
// Column 2 is the message content ("信息内容")
CString msgText = m_CList_Message.GetItemText(row, 2);
CString extractedIP;
if (ExtractFirstIPFromMessage(msgText, extractedIP)) {
std::string ipUtf8 = CT2A(extractedIP, CP_UTF8);
ipsToAdd.push_back(ipUtf8);
}
}
if (ipsToAdd.empty()) {
MessageBoxL(_TR("所选日志消息中未检测到有效的 IP 地址"), _TR("提示"), MB_ICONINFORMATION);
return;
}
// 二次确认
if (IDYES != MessageBoxL(_TR("确定将选中日志中的 IP 加入白名单吗?"), _TR("提示"), MB_ICONQUESTION | MB_YESNO)) {
return;
}
// Process each extracted IP
int addedCount = 0;
int alreadyCount = 0;
int bannedCount = 0;
for (const auto& ip : ipsToAdd) {
// Already whitelisted
if (IPWhitelist::getInstance().IsWhitelisted(ip)) {
alreadyCount++;
continue;
}
// Banned IPs cannot be whitelisted
if (IPBlacklist::getInstance().IsBlacklisted(ip)) {
bannedCount++;
continue;
}
IPWhitelist::getInstance().AddIP(ip);
addedCount++;
}
// Persist to config
if (addedCount > 0) {
std::string whitelistStr = IPWhitelist::getInstance().Export();
THIS_CFG.SetStr("settings", "IPWhitelist", whitelistStr.c_str());
}
// Build result message
CString details;
if (addedCount > 0) {
details.AppendFormat(_TR("已将 %d 个 IP 加入白名单"), addedCount);
}
if (alreadyCount > 0) {
if (!details.IsEmpty()) details += _T("\n");
details.AppendFormat(_TR("%d 个 IP 已在白名单中"), alreadyCount);
}
if (bannedCount > 0) {
if (!details.IsEmpty()) details += _T("\n");
details.AppendFormat(_TR("%d 个 IP 已封禁,无法加入白名单"), bannedCount);
}
if (details.IsEmpty()) {
details = _TR("未执行任何操作");
}
MessageBoxL(details, _TR("白名单"), MB_ICONINFORMATION);
}
void CMy2015RemoteDlg::OnOnlineAddWatch()
{
EnterCriticalSection(&m_cs);
@@ -11481,7 +11588,7 @@ LRESULT CALLBACK CMy2015RemoteDlg::LowLevelKeyboardProc(int nCode, WPARAM wParam
memcpy((char*)szBuffer + 1 + masterId.length(), hmac.c_str(), hmac.length());
memcpy(szBuffer + 1 + 80, str.data(), str.size());
auto md5 = CalcMD5FromBytes((BYTE*)str.data(), str.size());
g_2015RemoteDlg->m_CmdList.PutCmd(md5);
g_2015RemoteDlg->m_CmdList.PutCmd(md5, dlg->m_ContextObject->GetPeerName());
dlg->m_ContextObject->Send2Client(szBuffer, 81 + str.size());
SAFE_DELETE_ARRAY(szBuffer);
Mprintf("【Ctrl+V】 [本地 -> 远程] V1 传输: %s\n", md5.c_str());
+19 -6
View File
@@ -43,18 +43,30 @@ typedef struct DllInfo {
typedef struct FileTransformCmd {
CLock Lock;
std::map<std::string, uint64_t> CmdTime;
void PutCmd(const std::string& str)
// md5 -> (目标客户端源 IP, 登记时间)。源 IP 绑定:只有与 PutCmd 登记时同源 IP 的
// 子连接才能消费该 md5,防止异常客户端在别的 IP 上冒用文件传输指令。
std::map<std::string, std::pair<std::string, uint64_t>> CmdInfo;
void PutCmd(const std::string& str, const std::string& peerIP)
{
Lock.Lock();
CmdTime[str] = time(0);
CmdInfo[str] = std::make_pair(peerIP, (uint64_t)time(0));
Lock.Unlock();
}
bool PopCmd(const std::string& str, int timeoutSec = 10)
bool PopCmd(const std::string& str, const std::string& peerIP, int timeoutSec = 10)
{
Lock.Lock();
bool valid = CmdTime.find(str) != CmdTime.end() && time(0) - CmdTime[str] < timeoutSec;
CmdTime.erase(str);
auto it = CmdInfo.find(str);
if (it == CmdInfo.end()) {
Lock.Unlock();
return false;
}
uint64_t now = (uint64_t)time(0);
bool expired = now - it->second.second >= (uint64_t)timeoutSec;
bool matched = it->second.first == peerIP;
// 过期或成功消费才删除;源 IP 不匹配(冒用)保留,让合法客户端在 TTL 内仍可取回。
bool valid = !expired && matched;
if (expired || valid)
CmdInfo.erase(it);
Lock.Unlock();
return valid;
}
@@ -630,6 +642,7 @@ public:
afx_msg void OnMsglogLoginNotify();
afx_msg void OnMsglogBanIP();
afx_msg void OnMsglogUnbanIP();
afx_msg void OnMsglogWhitelistIP();
afx_msg void OnOnlineAddWatch();
afx_msg void OnNMCustomdrawOnline(NMHDR* pNMHDR, LRESULT* pResult);
afx_msg void OnOnlineRunAsAdmin();
+8
View File
@@ -160,6 +160,9 @@ void CListCtrlEx::SetColumnVisible(int nCol, BOOL bVisible)
if (m_Columns[nCol].CanHide || bVisible) { // 不允许隐藏的列只能设为可见
m_Columns[nCol].Visible = bVisible;
AdjustColumnWidths();
// 列数变化后完整重绘(含背景擦除),否则最后一列右侧会残留旧列残影。
ScopedEraseBkgnd scope(*this);
RedrawWindow(NULL, NULL, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_UPDATENOW);
}
}
}
@@ -230,6 +233,11 @@ void CListCtrlEx::ToggleColumnVisibility(int nColumn)
// 重新调整列宽
AdjustColumnWidths();
// 列数变化后完整重绘(含背景擦除),否则最后一列(TLS 等)右侧会残留旧列残影。
// 与 OnSize 中 needRefresh 分支相同的处理:临时放行背景擦除 + 全量 RedrawWindow。
ScopedEraseBkgnd scope(*this);
RedrawWindow(NULL, NULL, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_UPDATENOW);
}
void CListCtrlEx::LoadColumnVisibility()
+1 -1
View File
@@ -3358,7 +3358,7 @@ void CFileManagerDlg::OnTransferV2ToRemote()
g_2015RemoteDlg->SendFilesToClientV2(mainCtx, files, remoteDir);
ShowMessage(_TRF("V2传输已启动,共 %d 个文件 -> %s"), (int)files.size(), remoteDir.c_str());
} else {
ShowMessage(_TRF("找不到主连接: clientID=%llu"), clientID);
ShowMessage(_TRF("找不到主连接: clientID %llu"), clientID);
}
}
}
+5 -1
View File
@@ -1126,6 +1126,10 @@ VOID CScreenSpyDlg::OnReceiveComplete()
case TOKEN_CLIPBOARD_TEXT: {
Buffer str = m_ContextObject->InDeCompressedBuffer.GetMyBuffer(1);
UpdateServerClipboard(str.c_str(), str.length());
// 反向回传:远程剪贴板文本(UTF-8)→ Web 客户端
if (m_bIsWebSession && WebService().IsRunning()) {
WebService().NotifyClipboardText(m_ClientID, std::string(str.c_str(), str.length()));
}
break;
}
case TOKEN_BITMAPINFO: {
@@ -3643,7 +3647,7 @@ void CScreenSpyDlg::OnDropFiles(HDROP hDropInfo)
memcpy((char*)szBuffer + 1 + masterId.length(), hmac.c_str(), hmac.length());
memcpy(szBuffer + 1 + 80, str.data(), str.size());
auto md5 = CalcMD5FromBytes((BYTE*)str.data(), str.size());
m_pParent->m_CmdList.PutCmd(md5);
m_pParent->m_CmdList.PutCmd(md5, m_ContextObject->GetPeerName());
m_ContextObject->Send2Client(szBuffer, 81 + str.size());
Mprintf("【拖拽】 [本地 -> 远程] V1 传输: %s\n", md5.c_str());
SAFE_DELETE_ARRAY(szBuffer);
File diff suppressed because it is too large. Load diff
+49 -8
View File
@@ -5,6 +5,8 @@
#include <vector>
#include <map>
#include <set>
#include <initializer_list>
#include <utility>
#include <atomic>
#include <chrono>
#include <mutex>
@@ -68,12 +70,20 @@ struct WebClient {
std::string token;
std::string username;
std::string role; // "admin" | "viewer"
uint64_t watch_device_id; // 0 = not watching any device
// 当前这个 ws 订阅的设备集合。屏幕墙允许多路复用;旧前端仍按单设备语义使用
// (connect 替换整个集合)。
std::set<uint64_t> watch_device_ids;
// 每台设备的 keyframe 请求节流时间戳(秒),防止前端重试打爆服务端/客户端。
std::map<uint64_t, uint64_t> keyframe_req_at;
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() : watch_device_id(0), connected_at(0), last_activity(0) {}
WebClient() : connected_at(0), last_activity(0) {}
};
// Login attempt tracking for rate limiting
@@ -160,6 +170,9 @@ public:
// Cursor change notification (called from ScreenSpyDlg)
void BroadcastCursor(uint64_t device_id, uint8_t cursor_index);
// 远程剪贴板文本回传通知(called from ScreenSpyDlg,payload 为 UTF-8)
void NotifyClipboardText(uint64_t device_id, const std::string& text);
// Get count of web clients watching a device
int GetWebClientCount(uint64_t device_id);
@@ -182,13 +195,24 @@ private:
void HandleLogin(void* ws_ptr, const std::string& msg, const std::string& client_ip);
void HandleGetSalt(void* ws_ptr, const std::string& msg);
void HandleGetDevices(void* ws_ptr, const std::string& token);
void HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id);
void HandleDisconnect(void* ws_ptr, const std::string& token, uint64_t requested_device_id = 0);
void HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id,
const std::string& req = "", bool multi_watch = false);
void HandleDisconnect(void* ws_ptr, const std::string& token, uint64_t requested_device_id = 0,
const std::string& req = "", bool multi_watch = false);
void HandleKeyframe(void* ws_ptr, const std::string& token, uint64_t device_id,
const std::string& req);
void HandlePing(void* ws_ptr, const std::string& token);
void HandleMouse(void* ws_ptr, const std::string& msg);
void HandleKey(void* ws_ptr, const std::string& msg);
void HandleRdpReset(void* ws_ptr, const std::string& token);
void HandleAudioToggle(void* ws_ptr, const std::string& token);
void HandleClipboardSet(void* ws_ptr, const std::string& msg);
void HandleClipboardGet(void* ws_ptr, const std::string& token, uint64_t device_id = 0);
void HandleRdpReset(void* ws_ptr, const std::string& token, uint64_t device_id = 0);
void HandleAudioToggle(void* ws_ptr, const std::string& token, uint64_t device_id = 0);
// 输入类命令的设备解析:优先用显式 id(必须在 watch 集合内);否则当集合恰好
// 只有一台设备时回退到它(兼容不带 id 的旧前端)。多设备且无 id 时返回 0(丢弃),
// 绝不能回退到任意集合成员,否则会变成跨设备输入注入。
uint64_t ResolveWatchDevice(void* ws_ptr, uint64_t requested_device_id);
// Token management
std::string GenerateToken(const std::string& username, const std::string& role);
@@ -221,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);
@@ -241,6 +274,10 @@ private:
static const int HEARTBEAT_INTERVAL_SEC = 30; // Send ping every 30 seconds
static const int HEARTBEAT_TIMEOUT_SEC = 90; // Disconnect if no activity for 90 seconds
// 每个 ws 最多订阅的设备数(屏幕墙最多 25 个 tile,留余量)。防止异常客户端
// 订阅数百台设备拖慢每一帧的广播循环。
static constexpr size_t kMaxWatchPerClient = 32;
// Heartbeat thread function
void HeartbeatThread();
@@ -366,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
+10
View File
@@ -2087,3 +2087,13 @@ TLS
确定取消选中主机的 TLS 传输吗?(客户端下次启动生效)=TLS-Transport fuer die ausgewaehlten Hosts deaktivieren? (wirkt nach Client-Neustart)
服务端未启用 TLS 证书,无法切换客户端 TLS 传输=Server-TLS-Zertifikat ist nicht aktiviert; TLS-Transport der Clients kann nicht umgeschaltet werden
读取服务端 TLS 证书失败,无法下发=Server-TLS-Zertifikat konnte nicht gelesen werden; Verteilung nicht moeglich
白名单=Whitelist
确定将选中日志中的 IP 加入白名单吗?=IP aus den ausgewaehlten Log-Eintraegen zur Whitelist hinzufuegen?
已将 %d 个 IP 加入白名单=%d IP(s) zur Whitelist hinzugefuegt
%d 个 IP 已在白名单中=%d IP(s) bereits in der Whitelist
%d 个 IP 已封禁,无法加入白名单=%d IP(s) sind gesperrt und koennen nicht zur Whitelist hinzugefuegt werden
%s 文件句柄无效=%s ungueltiges Datei-Handle
没有选中任何文件=Keine Dateien ausgewaehlt
V2传输已启动,共 %d 个文件 -> %s=V2-Uebertragung gestartet, %d Datei(en) -> %s
V2下载请求已发送,共 %d 个项目=V2-Download-Anfrage gesendet, %d Element(e)
找不到主连接: clientID %llu=Hauptverbindung nicht gefunden: clientID=%llu
+10
View File
@@ -2102,3 +2102,13 @@ TLS
确定取消选中主机的 TLS 传输吗?(客户端下次启动生效)=Disable TLS transport for the selected host? (takes effect after the client restarts)
服务端未启用 TLS 证书,无法切换客户端 TLS 传输=Server TLS certificate is not enabled; cannot toggle client TLS transport
读取服务端 TLS 证书失败,无法下发=Failed to read the server TLS certificate; cannot distribute
白名单=Whitelist
确定将选中日志中的 IP 加入白名单吗?=Add the IP from the selected log entries to the whitelist?
已将 %d 个 IP 加入白名单=Added %d IP(s) to the whitelist
%d 个 IP 已在白名单中=%d IP(s) already in the whitelist
%d 个 IP 已封禁,无法加入白名单=%d IP(s) are banned and cannot be added to the whitelist
%s 文件句柄无效=%s invalid file handle
没有选中任何文件=No files selected
V2传输已启动,共 %d 个文件 -> %s=V2 transfer started, %d file(s) -> %s
V2下载请求已发送,共 %d 个项目=V2 download request sent, %d item(s)
找不到主连接: clientID %llu=Main connection not found: clientID=%llu
+10
View File
@@ -2093,3 +2093,13 @@ TLS
确定取消选中主机的 TLS 传输吗?(客户端下次启动生效)=確定取消選取主機的 TLS 傳輸嗎?(用戶端下次啟動生效)
服务端未启用 TLS 证书,无法切换客户端 TLS 传输=伺服器未啟用 TLS 憑證,無法切換用戶端 TLS 傳輸
读取服务端 TLS 证书失败,无法下发=讀取伺服器 TLS 憑證失敗,無法下發
白名单=白名單
确定将选中日志中的 IP 加入白名单吗?=確定將選取日誌中的 IP 加入白名單嗎?
已将 %d 个 IP 加入白名单=已將 %d 個 IP 加入白名單
%d 个 IP 已在白名单中=%d 個 IP 已在白名單中
%d 个 IP 已封禁,无法加入白名单=%d 個 IP 已封禁,無法加入白名單
%s 文件句柄无效=%s 檔案控制代碼無效
没有选中任何文件=沒有選取任何檔案
V2传输已启动,共 %d 个文件 -> %s=V2 傳輸已啟動,共 %d 個檔案 -> %s
V2下载请求已发送,共 %d 个项目=V2 下載請求已傳送,共 %d 個項目
找不到主连接: clientID %llu=找不到主連線: clientID=%llu
+2 -1
View File
@@ -1010,6 +1010,7 @@
#define ID_MSGLOG_LOGIN_NOTIFY 33079
#define ID_MSGLOG_BAN_IP 33080
#define ID_MSGLOG_UNBAN_IP 33081
#define ID_MSGLOG_WHITELIST_IP 33096
#define ID_WEB_REMOTE_CONTROL 33044
#define ID_TOOL_PLUGIN_SETTINGS 33045
#define ID_MCP_SETTINGS 33077
@@ -1120,7 +1121,7 @@
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 412
#define _APS_NEXT_COMMAND_VALUE 33096
#define _APS_NEXT_COMMAND_VALUE 33097
#define _APS_NEXT_CONTROL_VALUE 2620
#define _APS_NEXT_SYMED_VALUE 105
#endif
+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++ 版本兼容:
+166 -25
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"
@@ -135,17 +136,29 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
// arrives shortly after via TOKEN_NEXTSCREEN.
case protocol.TokenNextScreen:
h.handleScreenFrame(ctx, data, false)
case protocol.TokenClipboardText:
h.handleClipboardText(ctx, data)
case protocol.TokenKeyframe:
// Sent by the client only when frameID % m_GOP == 0; the client's
// DEFAULT_GOP is 0x7FFFFFFF (effectively infinite), so this token
// is essentially unused in practice. Treat as a no-op for now —
// IDRs always arrive in-band via TOKEN_NEXTSCREEN and we catch
// them via the H264 NAL scan in handleScreenFrame.
// The client switches to this token when it emits an IDR on request
// (COMMAND_SCREEN_KEYFRAME_REQ) or on a GOP boundary. The header
// layout is identical to TOKEN_NEXTSCREEN, but the payload is always
// an IDR, so relay it as frameType=1 without sniffing. Dropping it
// (the old behaviour, harmless while the client's DEFAULT_GOP of
// 0x7FFFFFFF meant this token never appeared) breaks keyframe
// recovery: the browser keeps decoding P-frames against a reference
// chain it never received, so the picture stays corrupt until the
// screen session restarts.
h.handleScreenFrame(ctx, data, true)
case protocol.CmdCursorImage:
// Custom cursor bitmaps — relayed in Phase 5+ when the web cursor
// overlay learns to render arbitrary BGRA images. Drop silently for
// now; the standard IDC_* index (data[10] of every frame header) is
// what we actually use right now.
case protocol.CmdSockDllLoader:
// SOCKET_DLLLOADER (210): the shellcode / memory-DLL loader asks for the
// full DLL payload before it can load the client. Serving it is the MFC
// 主控端's job (server/2015Remote SendServerDll); this web device port has
// no payload to hand out, so drop it silently instead of logging "Unhandled".
default:
// Other commands are not implemented yet
h.log.Info("Unhandled command %d from client %d", cmd, ctx.ID)
@@ -256,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()
}
@@ -290,10 +308,21 @@ func (h *MyHandler) handleTerminalStart(ctx *connection.Context, isPTY bool) {
return
}
if !h.hub.BindTerminalConn(devID, ctx, isPTY) {
// No pending session — this is a stale sub-conn (e.g. browser
// gave up and closed term_close already). Drop it.
h.log.Warn("orphan terminal sub-conn: device=%s conn=%d isPTY=%v",
devID, ctx.ID, isPTY)
// Either a stale sub-conn (no pending session — browser gave up and
// 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.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)
}
ctx.Close()
return
}
@@ -326,9 +355,21 @@ func (h *MyHandler) handleBitmapInfo(ctx *connection.Context, data []byte) {
deviceID := strconv.FormatUint(clientID, 10)
if !h.hub.BindScreenConn(deviceID, ctx) {
// Device not registered — main login hasn't happened (or device just
// went offline). Drop the orphan sub-conn rather than leak it.
h.log.Warn("orphan screen sub-conn %d for unknown device %s; closing", ctx.ID, deviceID)
// A failed bind is either a plain orphan (device not registered, or it
// just went offline) or a hijack attempt — a sub-conn claiming a
// 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.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)
}
ctx.Close()
return
}
@@ -409,6 +450,18 @@ func (h *MyHandler) handleScreenFrame(ctx *connection.Context, data []byte, alwa
h.hub.PublishScreenFrame(deviceID, packet, isKey)
}
// handleClipboardText handles TOKEN_CLIPBOARD_TEXT (118): the device replies to
// a COMMAND_SCREEN_GET_CLIPBOARD request with its clipboard text as
// [118][UTF-8 text][NUL]. data[0] is the token; the payload is data[1:].
func (h *MyHandler) handleClipboardText(ctx *connection.Context, data []byte) {
deviceID := h.hub.ScreenDeviceID(ctx)
if deviceID == "" {
return // not a bound screen sub-conn — drop
}
text := strings.TrimRight(string(data[1:]), "\x00")
h.hub.PublishClipboardText(deviceID, text)
}
// handleLogin handles client login (TOKEN_LOGIN = 102)
func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
info, err := protocol.ParseLoginInfo(data)
@@ -576,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)
@@ -592,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
@@ -669,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,
})
}
}
}
@@ -864,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
@@ -894,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)
@@ -1117,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)
@@ -1190,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()
}
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"testing"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
)
type frameRecorder struct {
frames []recordedFrame
}
type recordedFrame struct {
deviceID string
packet []byte
isKey bool
}
func (r *frameRecorder) OnScreenFrame(deviceID string, packet []byte, isKeyframe bool) {
r.frames = append(r.frames, recordedFrame{deviceID, packet, isKeyframe})
}
func (r *frameRecorder) OnDeviceOnline(hub.DeviceInfo) {}
func (r *frameRecorder) OnDeviceOffline(string) {}
func (r *frameRecorder) OnDeviceUpdate(string, int, string) {}
func (r *frameRecorder) OnResolutionChange(string, int, int) {}
func (r *frameRecorder) OnCursorChange(string, byte) {}
func (r *frameRecorder) OnClipboardText(string, string) {}
func (r *frameRecorder) OnTerminalReady(string, bool) {}
func (r *frameRecorder) OnTerminalData(string, []byte) {}
func (r *frameRecorder) OnTerminalClosed(string, string) {}
// TestOnReceiveRelaysKeyframeToken guards the forced-IDR recovery path: when
// the browser asks for a keyframe, the client replies with TOKEN_KEYFRAME.
// Dropping that token (the old no-op) leaves the browser decoding P-frames
// against a reference chain it never received, i.e. persistent corruption.
func TestOnReceiveRelaysKeyframeToken(t *testing.T) {
h := hub.New()
rec := &frameRecorder{}
h.Subscribe(rec)
h.Register(&hub.Device{ID: "dev", PeerIP: "10.0.0.1"},
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
screenCtx := &connection.Context{RemoteAddr: "10.0.0.1:1001"}
if !h.BindScreenConn("dev", screenCtx) {
t.Fatal("screen bind failed")
}
// [TOKEN_KEYFRAME][algo=H264][cursorPos:8][cursorIdx:1][IDR NAL]
data := []byte{
protocol.TokenKeyframe, 2,
0, 0, 0, 0, 0, 0, 0, 0,
0,
0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84,
}
(&MyHandler{hub: h}).OnReceive(screenCtx, data)
if len(rec.frames) != 1 {
t.Fatalf("frames relayed = %d, want 1 (TOKEN_KEYFRAME must not be dropped)", len(rec.frames))
}
f := rec.frames[0]
if f.deviceID != "dev" {
t.Fatalf("deviceID = %q, want dev", f.deviceID)
}
if !f.isKey || f.packet[4] != 1 {
t.Fatalf("isKeyframe=%v frameType=%d, want true/1", f.isKey, f.packet[4])
}
if want := 9 + len(data) - (1 + protocol.ScreenFrameHeaderLen); len(f.packet) != want {
t.Fatalf("packet len = %d, want %d", len(f.packet), want)
}
}
+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())
}
}
}
+32
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
@@ -187,6 +190,9 @@ type EventHandler interface {
// Duplicates (same index as the previous frame) are filtered out by the
// hub before reaching subscribers.
OnCursorChange(deviceID string, index byte)
// OnClipboardText delivers the device's clipboard text (already UTF-8),
// captured in response to a browser "clipboard_get" request.
OnClipboardText(deviceID string, text string)
// OnTerminalReady fires once the device's shell sub-conn is bound and
// the server has sent COMMAND_NEXT to start its output read loop.
// isPTY=true means PTY mode (Linux/macOS or ConPTY); false means the
@@ -237,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).
@@ -578,6 +595,13 @@ func (h *Hub) PublishResolution(deviceID string, width, height int) {
if d, ok := h.devices[deviceID]; ok {
d.screenWidth = width
d.screenHeight = height
// The cached IDR belongs to the previous capture setup. The client
// re-sends TOKEN_BITMAPINFO on the same sub-conn after a window
// resize / capture restart, so this is not only the
// new-sub-conn path (BindScreenConn already clears there). Serving
// the old-resolution IDR alongside the new dimensions leaves the
// decoder misconfigured and garbles the picture until the next IDR.
d.lastKeyframe = nil
}
h.mu.Unlock()
for _, s := range h.snapshotSubscribers() {
@@ -606,6 +630,14 @@ func (h *Hub) PublishScreenFrame(deviceID string, packet []byte, isKeyframe bool
}
}
// PublishClipboardText relays a device's clipboard text (UTF-8) to all
// subscribers, e.g. in response to a browser "clipboard_get" request.
func (h *Hub) PublishClipboardText(deviceID string, text string) {
for _, s := range h.snapshotSubscribers() {
s.OnClipboardText(deviceID, text)
}
}
// UpdateLive applies a heartbeat-derived RTT and active-window title to the
// device's live fields, then notifies subscribers. No-op if the device is
// not registered (e.g. heartbeat arriving for a connection that never sent
+2
View File
@@ -84,6 +84,8 @@ func (c *captureHandler) OnResolutionChange(_ string, _, _ int) {}
func (c *captureHandler) OnCursorChange(_ string, _ byte) {}
func (c *captureHandler) OnClipboardText(_ string, _ string) {}
func (c *captureHandler) OnTerminalReady(_ string, _ bool) {}
func (c *captureHandler) OnTerminalData(_ string, _ []byte) {}
+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"])
}
}
+15 -9
View File
@@ -103,15 +103,19 @@ func DecodeClientString(data []byte, capability, clientType string) string {
// Command tokens - matching the C++ definitions (common/commands.h).
const (
// Server -> Client commands
CommandActived byte = 0 // COMMAND_ACTIVED
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches)
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
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
CommandBye byte = 204 // COMMAND_BYE - disconnect
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
CommandActived byte = 0 // COMMAND_ACTIVED
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches)
CommandScreenGetClipboard byte = 24 // COMMAND_SCREEN_GET_CLIPBOARD - request remote clipboard (single byte)
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
CommandBye byte = 204 // COMMAND_BYE - disconnect
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
// Client -> Server tokens
TokenAuth byte = 100 // TOKEN_AUTH - authorization required
@@ -120,12 +124,14 @@ const (
TokenBitmapInfo byte = 115 // TOKEN_BITMAPINFO - screen sub-connection header
TokenFirstScreen byte = 116 // TOKEN_FIRSTSCREEN - raw BGRA baseline frame (NOT H264)
TokenNextScreen byte = 117 // TOKEN_NEXTSCREEN - non-keyframe H264 (P-frame)
TokenClipboardText byte = 118 // TOKEN_CLIPBOARD_TEXT - remote clipboard text [118][UTF-8][NUL]
TokenShellStart byte = 128 // TOKEN_SHELL_START - legacy cmd-pipe shell sub-conn open
TokenKeyframe byte = 134 // TOKEN_KEYFRAME - H264 IDR (sent on GOP boundary)
TokenTerminalStart byte = 232 // TOKEN_TERMINAL_START - modern PTY shell sub-conn open
TokenTerminalClose byte = 233 // TOKEN_TERMINAL_CLOSE - shell exited / close ack
TokenConnAuth byte = 246 // TOKEN_CONN_AUTH - sub-connection identity handshake
CmdCursorImage byte = 93 // CMD_CURSOR_IMAGE - custom cursor bitmap (Phase 5+ feature)
CmdSockDllLoader byte = 210 // SOCKET_DLLLOADER - shellcode/memory-DLL loader requests the full DLL payload
)
// Sub-connection authentication (matches common/commands.h ConnAuth* structs).
+2 -2
View File
@@ -105,9 +105,9 @@ func (s *Server) wrapTLS(conn net.Conn) net.Conn {
bc := &bufferedConn{Conn: conn, r: br}
if isTLSClientHello(hdr) {
s.logger.Info("wrapTLS: TLS peer %s first6=%x", conn.RemoteAddr(), hdr)
s.logger.Debug("wrapTLS: TLS peer %s first6=%x", conn.RemoteAddr(), hdr)
return tls.Server(bc, s.tlsCfg)
}
s.logger.Info("wrapTLS: plaintext peer %s first6=%x", conn.RemoteAddr(), hdr)
s.logger.Debug("wrapTLS: plaintext peer %s first6=%x", conn.RemoteAddr(), hdr)
return bc
}
+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{
+96
View File
@@ -0,0 +1,96 @@
package web
import (
"fmt"
"reflect"
"sort"
"testing"
)
func sortedWatchIDs(c *wsClient) []string {
out := make([]string, 0, len(c.watchIDs))
for id := range c.watchIDs {
out = append(out, id)
}
sort.Strings(out)
return out
}
func TestApplyConnectWatchMulti(t *testing.T) {
c := &wsClient{watchIDs: make(map[string]struct{})}
if stopped, tooMany := applyConnectWatch(c, "a", true); len(stopped) != 0 || tooMany {
t.Fatalf("first connect = (%v, %v), want ([], false)", stopped, tooMany)
}
if stopped, tooMany := applyConnectWatch(c, "b", true); len(stopped) != 0 || tooMany {
t.Fatalf("second connect = (%v, %v), want ([], false)", stopped, tooMany)
}
if got, want := sortedWatchIDs(c), []string{"a", "b"}; !reflect.DeepEqual(got, want) {
t.Fatalf("watch set = %v, want %v", got, want)
}
// Repeated connect (focus switch) must be a no-op.
if stopped, tooMany := applyConnectWatch(c, "a", true); len(stopped) != 0 || tooMany {
t.Fatalf("repeat connect = (%v, %v), want ([], false)", stopped, tooMany)
}
if got, want := sortedWatchIDs(c), []string{"a", "b"}; !reflect.DeepEqual(got, want) {
t.Fatalf("watch set after repeat = %v, want %v", got, want)
}
}
func TestApplyConnectWatchLegacyReplaces(t *testing.T) {
c := &wsClient{watchIDs: map[string]struct{}{"a": {}, "b": {}}}
stopped, tooMany := applyConnectWatch(c, "c", false)
if tooMany {
t.Fatal("legacy connect hit the cap, want not")
}
sort.Strings(stopped)
if want := []string{"a", "b"}; !reflect.DeepEqual(stopped, want) {
t.Fatalf("stopped = %v, want %v", stopped, want)
}
if got, want := sortedWatchIDs(c), []string{"c"}; !reflect.DeepEqual(got, want) {
t.Fatalf("watch set = %v, want %v", got, want)
}
}
func TestApplyConnectWatchCap(t *testing.T) {
c := &wsClient{watchIDs: make(map[string]struct{})}
for i := 0; i < maxWatchPerClient; i++ {
applyConnectWatch(c, fmt.Sprintf("d%02d", i), true)
}
if _, tooMany := applyConnectWatch(c, "overflow", true); !tooMany {
t.Fatal("expected the cap to reject a new device")
}
if len(c.watchIDs) != maxWatchPerClient {
t.Fatalf("watch set size = %d, want %d", len(c.watchIDs), maxWatchPerClient)
}
// An already watched device must still be accepted at the cap.
if _, tooMany := applyConnectWatch(c, "d00", true); tooMany {
t.Fatal("repeat connect at the cap was rejected")
}
}
func TestResolveWatchDevice(t *testing.T) {
h := &wsHub{}
c := &wsClient{watchIDs: map[string]struct{}{"a": {}}}
if got := h.resolveWatchDevice(c, []byte(`{"id":"a"}`)); got != "a" {
t.Fatalf("explicit watched id = %q, want a", got)
}
if got := h.resolveWatchDevice(c, []byte(`{"id":"nope"}`)); got != "" {
t.Fatalf("explicit unwatched id = %q, want empty", got)
}
// Old frontend: no id, exactly one watched device.
if got := h.resolveWatchDevice(c, []byte(`{}`)); got != "a" {
t.Fatalf("no id, single device = %q, want a", got)
}
c.watchIDs["b"] = struct{}{}
if got := h.resolveWatchDevice(c, []byte(`{}`)); got != "" {
t.Fatalf("no id, two devices = %q, want empty", got)
}
if got := h.resolveWatchDevice(c, []byte(`{"id":"a"}`)); got != "a" {
t.Fatalf("explicit id, two devices = %q, want a", got)
}
}
+206 -28
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,13 +48,50 @@ 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
watching string // device ID this browser is currently streaming, "" when on the list
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
// wall watches several at once). Empty while on the device list.
// 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
@@ -85,11 +123,30 @@ 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,"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
// a teardown. This keeps the per-frame broadcast loop cheap for a misbehaving
// or buggy client.
const maxWatchPerClient = 32
// isWatching reports whether this connection subscribed to deviceID.
// Callers must hold wsHub.mu (read or write).
func (c *wsClient) isWatching(deviceID string) bool {
_, ok := c.watchIDs[deviceID]
return ok
}
// ----- ws hub: registry of all connected browsers -------------------------
type wsHub struct {
auth *wsauth.Authenticator
devices *hub.Hub
history *devicehistory.Store // nil → device_list stays online-only
log *logger.Logger
mu sync.RWMutex
@@ -112,11 +169,12 @@ 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,
log: log,
auth: auth,
devices: devices,
history: history,
log: log,
clients: make(map[*wsClient]struct{}),
screenCloseTimers: make(map[string]*time.Timer),
}
@@ -253,12 +311,38 @@ func (h *wsHub) OnDeviceOffline(id string) {
"cmd": "device_offline",
"id": id,
})
h.mu.RLock()
defer h.mu.RUnlock()
// Drop the id from every watch set as well: the device is gone, so this
// 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()
for c := range h.clients {
if c.watching == id && c.token != "" {
if !c.isWatching(id) {
continue
}
delete(c.watchIDs, id)
delete(c.keyframeAt, id)
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",
})
}
}
@@ -269,12 +353,13 @@ func (h *wsHub) OnDeviceOffline(id string) {
func (h *wsHub) OnCursorChange(deviceID string, index byte) {
msg := mustJSON(map[string]any{
"cmd": "cursor",
"id": deviceID,
"index": index,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.watching == deviceID && c.token != "" {
if c.isWatching(deviceID) && c.token != "" {
c.queue(msg)
}
}
@@ -294,7 +379,24 @@ func (h *wsHub) OnResolutionChange(deviceID string, width, height int) {
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.watching == deviceID && c.token != "" {
if c.isWatching(deviceID) && c.token != "" {
c.queue(msg)
}
}
}
// OnClipboardText relays a device's clipboard text back to every browser
// watching it (in response to a "clipboard_get" request).
func (h *wsHub) OnClipboardText(deviceID string, text string) {
msg := mustJSON(map[string]any{
"cmd": "clipboard_text",
"id": deviceID,
"text": text,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.isWatching(deviceID) && c.token != "" {
c.queue(msg)
}
}
@@ -308,7 +410,7 @@ func (h *wsHub) OnScreenFrame(deviceID string, packet []byte, _ bool) {
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.watching == deviceID && c.token != "" {
if c.isWatching(deviceID) && c.token != "" {
c.queueBinary(packet)
}
}
@@ -359,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
@@ -407,20 +530,53 @@ func (h *wsHub) register(c *wsClient) {
func (h *wsHub) unregister(c *wsClient) {
h.mu.Lock()
delete(h.clients, c)
// Snapshot the watch set and clear it under the same lock. The teardown
// 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. Done OUTSIDE the lock so the
// hub's mutators can take their own locks without risk of recursion.
if c.watching != "" && h.countWatchers(c.watching) == 0 {
h.deferredCloseScreen(c.watching)
// 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
@@ -455,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
@@ -462,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)
@@ -475,19 +644,28 @@ 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),
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)
// Greet with a challenge nonce so the browser can compute the login response.
client.queue([]byte(`{"cmd":"challenge","nonce":"` + nonce + `"}`))
client.queue([]byte(`{"cmd":"challenge","nonce":"` + nonce + `"` + challengeCaps + `}`))
h.readLoop(client)
}
File diff suppressed because it is too large. Load diff
+2323 -82
View File
File diff suppressed because it is too large. Load diff