Compare commits
15
Commits
614b03994c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca671d6453 | ||
|
|
a522424491 | ||
|
|
b1ddec1b8a | ||
|
|
1922f87dd2 | ||
|
|
8eab26c1f6 | ||
|
|
04f4eb9947 | ||
|
|
5cea1167be | ||
|
|
0c04f7516b | ||
|
|
e6b4fe3a85 | ||
|
|
d3a339247b | ||
|
|
33986f4893 | ||
|
|
e5f5720867 | ||
|
|
d7b49a3d06 | ||
|
|
c85039ea0a | ||
|
|
a447452521 |
No files matched your search
@@ -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
@@ -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());
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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, // 注册表
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+849
-116
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// Package devicehistory persists the set of devices that have ever logged in
|
||||
// to the Go server, so the web UI can keep listing them after they go offline.
|
||||
//
|
||||
// It implements hub.EventHandler and is subscribed to the hub, which means the
|
||||
// only wiring needed at startup is hub.Subscribe(store). Online events upsert
|
||||
// the record (refreshing the display fields the client reports at login) and
|
||||
// offline events merely refresh last_seen.
|
||||
//
|
||||
// Storage is a small JSON file, mirroring the wsauth users.json convention:
|
||||
// atomic temp-file + fsync + rename, loaded once at startup and written back
|
||||
// on a short debounce so a burst of reconnects doesn't rewrite it per device.
|
||||
package devicehistory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// fileVersion lets a future release migrate the on-disk shape. Unknown
|
||||
// versions are still parsed best-effort; only structurally invalid JSON
|
||||
// makes Open fail.
|
||||
fileVersion = 1
|
||||
|
||||
// flushDelay coalesces updates. Device logins/disconnects are sparse, but
|
||||
// a server restart makes every agent reconnect at once; without the
|
||||
// debounce that would be one file write per device.
|
||||
flushDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// Record is one persisted device. The fields mirror hub.DeviceInfo so the
|
||||
// offline list can be rendered with the same front-end card code; only the
|
||||
// fields that are stable across sessions are kept (RTT / active window are
|
||||
// live-only and would be stale noise on an offline card).
|
||||
type Record struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Capability string `json:"capability,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
CPU string `json:"cpu,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
InstallTime string `json:"install_time,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
PeerIP string `json:"peer_ip,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Screen string `json:"screen,omitempty"`
|
||||
|
||||
FirstSeen int64 `json:"first_seen"`
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
}
|
||||
|
||||
type fileShape struct {
|
||||
Version int `json:"version"`
|
||||
Devices map[string]*Record `json:"devices"`
|
||||
}
|
||||
|
||||
// Store is the in-memory device history plus its backing file. Safe for
|
||||
// concurrent use.
|
||||
type Store struct {
|
||||
path string
|
||||
max int // 0 = unlimited
|
||||
log *logger.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
devices map[string]*Record
|
||||
// online is derived from hub events and used only by pruning: a device
|
||||
// that is still connected must keep its record even if its last login is
|
||||
// older than the 1000th offline device's. Not persisted — after a restart
|
||||
// every device is offline until it reconnects.
|
||||
online map[string]bool
|
||||
dirty bool
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
// Open loads the history at path. A missing file is an empty history, not an
|
||||
// error. If the file exists but cannot be parsed, the returned store is still
|
||||
// usable (empty) along with the error, so callers can log and continue — the
|
||||
// next flush will replace the corrupt file.
|
||||
func Open(path string, max int, log *logger.Logger) (*Store, error) {
|
||||
s := newStore(path, max, log)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
return s, err
|
||||
}
|
||||
var f fileShape
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return s, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
for id, r := range f.Devices {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
// The map key is authoritative; tolerate records written by a
|
||||
// future/older shape that omitted or disagreed with the id field.
|
||||
r.ID = id
|
||||
s.devices[id] = r
|
||||
}
|
||||
s.pruneLocked()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// New returns an empty store for path without touching disk. Useful when Open
|
||||
// failed and the caller still wants live recording to work.
|
||||
func New(path string, max int, log *logger.Logger) *Store {
|
||||
return newStore(path, max, log)
|
||||
}
|
||||
|
||||
func newStore(path string, max int, log *logger.Logger) *Store {
|
||||
return &Store{
|
||||
path: path,
|
||||
max: max,
|
||||
log: log,
|
||||
devices: make(map[string]*Record),
|
||||
online: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// OnDeviceOnline records a device that just logged in. It refreshes every
|
||||
// display field because the agent reports its current name/group/version at
|
||||
// each login, and preserves first_seen across reconnects.
|
||||
func (s *Store) OnDeviceOnline(d hub.DeviceInfo) {
|
||||
s.upsertAt(d, time.Now())
|
||||
}
|
||||
|
||||
func (s *Store) upsertAt(d hub.DeviceInfo, at time.Time) {
|
||||
if d.ID == "" {
|
||||
return
|
||||
}
|
||||
now := at.Unix()
|
||||
|
||||
s.mu.Lock()
|
||||
rec, ok := s.devices[d.ID]
|
||||
if !ok {
|
||||
rec = &Record{ID: d.ID, FirstSeen: now}
|
||||
s.devices[d.ID] = rec
|
||||
}
|
||||
if rec.FirstSeen == 0 {
|
||||
rec.FirstSeen = now
|
||||
}
|
||||
rec.Name = d.Name
|
||||
rec.Group = d.Group
|
||||
rec.Version = d.Version
|
||||
rec.Capability = d.Capability
|
||||
rec.OS = d.OS
|
||||
rec.CPU = d.CPU
|
||||
rec.FilePath = d.FilePath
|
||||
rec.InstallTime = d.InstallTime
|
||||
rec.Location = d.Location
|
||||
rec.PeerIP = d.PeerIP
|
||||
rec.IP = d.IP
|
||||
rec.Screen = d.Screen
|
||||
rec.LastSeen = now
|
||||
s.online[d.ID] = true
|
||||
|
||||
s.pruneLocked()
|
||||
s.markDirtyLocked()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// OnDeviceOffline refreshes last_seen. The record was created when the device
|
||||
// logged in; an unknown ID is ignored (e.g. a device rejected by signing never
|
||||
// reaches the hub).
|
||||
func (s *Store) OnDeviceOffline(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
s.offlineAt(id, time.Now())
|
||||
}
|
||||
|
||||
func (s *Store) offlineAt(id string, at time.Time) {
|
||||
s.mu.Lock()
|
||||
delete(s.online, id)
|
||||
if rec, ok := s.devices[id]; ok {
|
||||
rec.LastSeen = at.Unix()
|
||||
s.markDirtyLocked()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// The remaining EventHandler methods are not relevant to device history.
|
||||
func (s *Store) OnDeviceUpdate(string, int, string) {}
|
||||
func (s *Store) OnScreenFrame(string, []byte, bool) {}
|
||||
func (s *Store) OnResolutionChange(string, int, int) {}
|
||||
func (s *Store) OnCursorChange(string, byte) {}
|
||||
func (s *Store) OnClipboardText(string, string) {}
|
||||
func (s *Store) OnTerminalReady(string, bool) {}
|
||||
func (s *Store) OnTerminalData(string, []byte) {}
|
||||
func (s *Store) OnTerminalClosed(string, string) {}
|
||||
|
||||
// Offline returns the records whose IDs are not in online, newest last_seen
|
||||
// first, converted to the hub's device payload shape with Online=false.
|
||||
//
|
||||
// Callers pass the hub's current online snapshot; the store deliberately does
|
||||
// not know which device is online, so a reconnect that re-registers the same
|
||||
// ID can never produce a duplicate row.
|
||||
func (s *Store) Offline(online map[string]struct{}) []hub.DeviceInfo {
|
||||
s.mu.Lock()
|
||||
out := make([]hub.DeviceInfo, 0, len(s.devices))
|
||||
for id, rec := range s.devices {
|
||||
if _, ok := online[id]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, hub.DeviceInfo{
|
||||
ID: rec.ID,
|
||||
Name: rec.Name,
|
||||
Group: rec.Group,
|
||||
Version: rec.Version,
|
||||
Capability: rec.Capability,
|
||||
OS: rec.OS,
|
||||
CPU: rec.CPU,
|
||||
FilePath: rec.FilePath,
|
||||
InstallTime: rec.InstallTime,
|
||||
Location: rec.Location,
|
||||
IP: rec.IP,
|
||||
PeerIP: rec.PeerIP,
|
||||
Screen: rec.Screen,
|
||||
LastSeen: rec.LastSeen,
|
||||
Online: false,
|
||||
})
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].LastSeen != out[j].LastSeen {
|
||||
return out[i].LastSeen > out[j].LastSeen
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Remove deletes the persisted record for id and reports whether one existed.
|
||||
// This is the web UI's explicit "delete" for an offline device: an offline
|
||||
// record has no live agent to uninstall, so forgetting it is all "delete" can
|
||||
// mean. The caller decides when removal is appropriate; the store does not
|
||||
// refuse to drop a record that happens to be marked online.
|
||||
func (s *Store) Remove(id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
_, existed := s.devices[id]
|
||||
if existed {
|
||||
delete(s.devices, id)
|
||||
delete(s.online, id)
|
||||
s.markDirtyLocked()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return existed
|
||||
}
|
||||
|
||||
// Flush writes the current state synchronously. Normally the debounced timer
|
||||
// does this; Flush exists for tests and graceful shutdown.
|
||||
func (s *Store) Flush() error {
|
||||
s.mu.Lock()
|
||||
if s.timer != nil {
|
||||
s.timer.Stop()
|
||||
s.timer = nil
|
||||
}
|
||||
if !s.dirty {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.dirty = false
|
||||
data, err := s.marshalLocked()
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(s.path, data); err != nil {
|
||||
s.mu.Lock()
|
||||
s.dirty = true
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close stops the debounce timer and flushes pending changes.
|
||||
func (s *Store) Close() error {
|
||||
return s.Flush()
|
||||
}
|
||||
|
||||
func (s *Store) markDirtyLocked() {
|
||||
s.dirty = true
|
||||
if s.timer == nil {
|
||||
s.timer = time.AfterFunc(flushDelay, s.flush)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) flush() {
|
||||
if err := s.Flush(); err != nil && s.log != nil {
|
||||
s.log.Error("Failed to persist device history to %s: %v", s.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) marshalLocked() ([]byte, error) {
|
||||
f := fileShape{Version: fileVersion, Devices: s.devices}
|
||||
return json.MarshalIndent(f, "", " ")
|
||||
}
|
||||
|
||||
// pruneLocked keeps the max most-recently-seen records. It runs after every
|
||||
// online upsert, which is the only path that can grow the map; the O(n log n)
|
||||
// sort only happens when the cap is actually exceeded.
|
||||
func (s *Store) pruneLocked() {
|
||||
if s.max <= 0 || len(s.devices) <= s.max {
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(s.devices))
|
||||
for id := range s.devices {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
oi, oj := s.online[ids[i]], s.online[ids[j]]
|
||||
if oi != oj {
|
||||
return !oi // evict offline records before online ones
|
||||
}
|
||||
return s.devices[ids[i]].LastSeen < s.devices[ids[j]].LastSeen
|
||||
})
|
||||
for _, id := range ids[:len(ids)-s.max] {
|
||||
delete(s.devices, id)
|
||||
}
|
||||
}
|
||||
|
||||
// writeAtomic mirrors wsauth.writeUsersFile: temp file in the same directory,
|
||||
// fsync, then rename over the destination.
|
||||
func writeAtomic(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "devices-*.json.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package devicehistory
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
)
|
||||
|
||||
func testDevice(id, name string) hub.DeviceInfo {
|
||||
return hub.DeviceInfo{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Group: "office",
|
||||
Version: "1.2.3",
|
||||
OS: "Windows 11",
|
||||
IP: "203.0.113.7",
|
||||
PeerIP: "198.51.100.9",
|
||||
Screen: "1:1920*1080",
|
||||
Online: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRecordsLifecycleViaHub(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
s, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
h := hub.New()
|
||||
h.Subscribe(s)
|
||||
|
||||
h.Register(&hub.Device{ID: "dev-1", Name: "PC-1", Group: "office"},
|
||||
&connection.Context{RemoteAddr: "198.51.100.9:5000"})
|
||||
|
||||
// The device is online, so it must not appear in the offline list.
|
||||
if got := s.Offline(map[string]struct{}{"dev-1": {}}); len(got) != 0 {
|
||||
t.Fatalf("offline while online = %d, want 0", len(got))
|
||||
}
|
||||
|
||||
h.Unregister("dev-1")
|
||||
|
||||
off := s.Offline(nil)
|
||||
if len(off) != 1 {
|
||||
t.Fatalf("offline entries = %d, want 1", len(off))
|
||||
}
|
||||
d := off[0]
|
||||
if d.ID != "dev-1" || d.Name != "PC-1" || d.Group != "office" {
|
||||
t.Fatalf("offline device = %+v", d)
|
||||
}
|
||||
if d.Online {
|
||||
t.Fatal("offline entry marked online")
|
||||
}
|
||||
if d.LastSeen == 0 {
|
||||
t.Fatal("last_seen not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorePersistsAndPreservesFirstSeen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
s, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
|
||||
first := time.Unix(1_700_000_000, 0)
|
||||
s.upsertAt(testDevice("dev-1", "old-name"), first)
|
||||
// A later login refreshes display fields and last_seen but keeps first_seen.
|
||||
s.upsertAt(testDevice("dev-1", "new-name"), first.Add(time.Hour))
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
_ = s.Close()
|
||||
|
||||
reopened, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reopened.Close() })
|
||||
|
||||
reopened.mu.Lock()
|
||||
rec := reopened.devices["dev-1"]
|
||||
reopened.mu.Unlock()
|
||||
if rec == nil {
|
||||
t.Fatal("record not persisted")
|
||||
}
|
||||
if rec.Name != "new-name" {
|
||||
t.Fatalf("name = %q, want new-name", rec.Name)
|
||||
}
|
||||
if rec.FirstSeen != first.Unix() {
|
||||
t.Fatalf("first_seen = %d, want %d", rec.FirstSeen, first.Unix())
|
||||
}
|
||||
if rec.LastSeen != first.Add(time.Hour).Unix() {
|
||||
t.Fatalf("last_seen = %d, want %d", rec.LastSeen, first.Add(time.Hour).Unix())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorePrunesOldest(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
s, err := Open(path, 2, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
s.upsertAt(testDevice("a", "A"), base)
|
||||
s.upsertAt(testDevice("b", "B"), base.Add(time.Minute))
|
||||
s.upsertAt(testDevice("c", "C"), base.Add(2*time.Minute))
|
||||
|
||||
s.mu.Lock()
|
||||
_, hasA := s.devices["a"]
|
||||
_, hasC := s.devices["c"]
|
||||
n := len(s.devices)
|
||||
s.mu.Unlock()
|
||||
if n != 2 || hasA || !hasC {
|
||||
t.Fatalf("after prune: n=%d hasA=%v hasC=%v, want 2/false/true", n, hasA, hasC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEvictsOfflineBeforeOnline(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
s, err := Open(path, 2, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
s.upsertAt(testDevice("online-old", "Old"), base)
|
||||
s.upsertAt(testDevice("offline-new", "Gone"), base.Add(time.Minute))
|
||||
s.offlineAt("offline-new", base.Add(2*time.Minute))
|
||||
// This upsert exceeds the cap; the offline record must be evicted before
|
||||
// the still-connected device, even though its last_seen is newer.
|
||||
s.upsertAt(testDevice("online-2", "New"), base.Add(3*time.Minute))
|
||||
|
||||
s.mu.Lock()
|
||||
_, hasOld := s.devices["online-old"]
|
||||
_, hasGone := s.devices["offline-new"]
|
||||
_, hasNew := s.devices["online-2"]
|
||||
n := len(s.devices)
|
||||
s.mu.Unlock()
|
||||
if n != 2 || !hasOld || hasGone || !hasNew {
|
||||
t.Fatalf("after prune: n=%d hasOld=%v hasGone=%v hasNew=%v", n, hasOld, hasGone, hasNew)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreOfflineSortedNewestFirst(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
s, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
s.upsertAt(testDevice("old", "Old"), base)
|
||||
s.upsertAt(testDevice("new", "New"), base.Add(time.Hour))
|
||||
|
||||
off := s.Offline(nil)
|
||||
if len(off) != 2 || off[0].ID != "new" || off[1].ID != "old" {
|
||||
t.Fatalf("offline order = %+v, want new then old", off)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCorruptFileStartsEmptyAndRecovers(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s, err := Open(path, 1000, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Open corrupt file: want error, got nil")
|
||||
}
|
||||
if len(s.devices) != 0 {
|
||||
t.Fatalf("corrupt load produced %d records, want 0", len(s.devices))
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatalf("Flush after corrupt load: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reopened.Close() })
|
||||
if len(reopened.devices) != 1 {
|
||||
t.Fatalf("records after recovery = %d, want 1", len(reopened.devices))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRemovePersistsDeletion(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "devices.json")
|
||||
s, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
s.upsertAt(testDevice("gone", "Gone"), base)
|
||||
s.upsertAt(testDevice("kept", "Kept"), base.Add(time.Minute))
|
||||
|
||||
if !s.Remove("gone") {
|
||||
t.Fatal("Remove existing record = false, want true")
|
||||
}
|
||||
if s.Remove("gone") {
|
||||
t.Fatal("Remove missing record = true, want false")
|
||||
}
|
||||
if got := s.Offline(nil); len(got) != 1 || got[0].ID != "kept" {
|
||||
t.Fatalf("offline after remove = %+v, want only kept", got)
|
||||
}
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
_ = s.Close()
|
||||
|
||||
reopened, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reopened.Close() })
|
||||
if _, ok := reopened.devices["gone"]; ok {
|
||||
t.Fatal("removed record came back after reload")
|
||||
}
|
||||
if _, ok := reopened.devices["kept"]; !ok {
|
||||
t.Fatal("kept record missing after reload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushLeavesNoTempFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "devices.json")
|
||||
s, err := Open(path, 1000, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".tmp") {
|
||||
t.Fatalf("leftover temp file: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,9 @@ type DeviceInfo struct {
|
||||
ActiveWindow string `json:"activeWindow,omitempty"`
|
||||
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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Stable audit event names. Keep these stable: operators build alerts and
|
||||
// dashboards on them. Every record also carries audit=true.
|
||||
const (
|
||||
auditWSConnect = "web.ws.connect"
|
||||
auditWSReject = "web.ws.reject"
|
||||
auditWSClose = "web.ws.close"
|
||||
auditLoginOK = "web.login.success"
|
||||
auditLoginFail = "web.login.failure"
|
||||
auditLoginThrot = "web.login.throttled"
|
||||
auditSession = "web.session.resume"
|
||||
auditBadToken = "web.auth.invalid_token"
|
||||
auditDenied = "web.authz.denied"
|
||||
auditUnknownCmd = "web.command.unknown"
|
||||
auditSaltProbe = "web.salt.unknown_user"
|
||||
|
||||
auditScreenStart = "remote.screen.start"
|
||||
auditScreenStop = "remote.screen.stop"
|
||||
auditScreenDenied = "remote.screen.denied"
|
||||
auditTermStart = "remote.terminal.start"
|
||||
auditTermStop = "remote.terminal.stop"
|
||||
auditTermDenied = "remote.terminal.denied"
|
||||
auditClipSet = "remote.clipboard.set"
|
||||
auditClipGet = "remote.clipboard.get"
|
||||
auditRdpReset = "remote.rdp_reset"
|
||||
|
||||
auditUserCreate = "admin.user.create"
|
||||
auditUserDelete = "admin.user.delete"
|
||||
auditDeviceAdmin = "admin.device.delete"
|
||||
auditQuery = "admin.audit.query"
|
||||
)
|
||||
|
||||
// audit emits one audit record for a WebSocket client. Identity fields are
|
||||
// read under h.mu because requireAuth/handleLogin install them there and
|
||||
// broadcast goroutines may observe the same client concurrently.
|
||||
//
|
||||
// Callers MUST NOT hold h.mu when calling this method.
|
||||
func (h *wsHub) audit(c *wsClient, warn bool, event string, fields map[string]any) {
|
||||
if fields == nil {
|
||||
fields = make(map[string]any)
|
||||
}
|
||||
if c != nil {
|
||||
h.mu.RLock()
|
||||
connID := c.id
|
||||
addr := c.addr
|
||||
peer := c.peerAddr
|
||||
user := c.user
|
||||
role := c.role
|
||||
h.mu.RUnlock()
|
||||
|
||||
if _, ok := fields["conn"]; !ok && connID != "" {
|
||||
fields["conn"] = connID
|
||||
}
|
||||
if _, ok := fields["ip"]; !ok && addr != "" {
|
||||
fields["ip"] = addr
|
||||
}
|
||||
if _, ok := fields["peer_ip"]; !ok && peer != "" && peer != addr {
|
||||
fields["peer_ip"] = peer
|
||||
}
|
||||
if _, ok := fields["actor"]; !ok && user != "" {
|
||||
fields["actor"] = user
|
||||
}
|
||||
if _, ok := fields["role"]; !ok && role != "" {
|
||||
fields["role"] = role
|
||||
}
|
||||
}
|
||||
if warn {
|
||||
h.log.AuditWarn(event, fields)
|
||||
} else {
|
||||
h.log.Audit(event, fields)
|
||||
}
|
||||
}
|
||||
|
||||
// auditClose records the end of a browser WebSocket. Anonymous scanners that
|
||||
// connect and do nothing produce only the connect event; authenticated
|
||||
// sessions, failed-login probes, and command traffic always produce a close
|
||||
// record with counters that help spot brute-force / probing behavior.
|
||||
func (h *wsHub) auditClose(c *wsClient, screens int) {
|
||||
suspicious := c.statInvalidToken > 0 ||
|
||||
c.statLoginFailures > 0 ||
|
||||
c.statAdminDenied > 0 ||
|
||||
c.statUnknownSalt > 0 ||
|
||||
c.statUnknownCmd > 0
|
||||
if c.user == "" && c.statCommands == 0 && !suspicious {
|
||||
return
|
||||
}
|
||||
|
||||
fields := map[string]any{
|
||||
"duration_ms": time.Since(c.connectedAt).Milliseconds(),
|
||||
"commands": c.statCommands,
|
||||
}
|
||||
if screens > 0 {
|
||||
fields["screens"] = screens
|
||||
}
|
||||
if c.statInvalidToken > 0 {
|
||||
fields["invalid_tokens"] = c.statInvalidToken
|
||||
}
|
||||
if c.statLoginFailures > 0 {
|
||||
fields["login_failures"] = c.statLoginFailures
|
||||
}
|
||||
if c.statAdminDenied > 0 {
|
||||
fields["admin_denied"] = c.statAdminDenied
|
||||
}
|
||||
if c.statUnknownSalt > 0 {
|
||||
fields["unknown_salt"] = c.statUnknownSalt
|
||||
}
|
||||
if c.statUnknownCmd > 0 {
|
||||
fields["unknown_commands"] = c.statUnknownCmd
|
||||
}
|
||||
h.audit(c, suspicious, auditWSClose, fields)
|
||||
}
|
||||
|
||||
// requestFields builds the audit context available before a WebSocket client
|
||||
// exists (upgrade rejected) or for plain HTTP endpoints (SSO).
|
||||
func requestFields(r *http.Request, trustForwardedFor bool) map[string]any {
|
||||
fields := make(map[string]any)
|
||||
if r == nil {
|
||||
return fields
|
||||
}
|
||||
ip := clientIP(r, trustForwardedFor)
|
||||
if ip != "" {
|
||||
fields["ip"] = ip
|
||||
}
|
||||
if peer := stripPort(r.RemoteAddr); peer != "" && peer != ip {
|
||||
fields["peer_ip"] = peer
|
||||
}
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
fields["origin"] = origin
|
||||
}
|
||||
if ua := r.UserAgent(); ua != "" {
|
||||
fields["ua"] = ua
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// wsRequestFields returns request metadata worth attaching to login events.
|
||||
func (c *wsClient) wsRequestFields() map[string]any {
|
||||
fields := make(map[string]any)
|
||||
if c.origin != "" {
|
||||
fields["origin"] = c.origin
|
||||
}
|
||||
if c.userAgent != "" {
|
||||
fields["ua"] = c.userAgent
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func durationMs(start time.Time) int64 {
|
||||
if start.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return time.Since(start).Milliseconds()
|
||||
}
|
||||
|
||||
// newConnID returns a short random identifier used to correlate all audit
|
||||
// records belonging to one browser WebSocket connection.
|
||||
func newConnID() string {
|
||||
var b [4]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "conn"
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Audit-log query support for the admin-only "Audit Log" page.
|
||||
//
|
||||
// The server reads its own JSON-lines audit file (logs/audit.log) back to the
|
||||
// browser. Records are already redacted at write time (see logger.AuditAt), so
|
||||
// returning raw lines is safe as long as we never widen what is written.
|
||||
//
|
||||
// Only the CURRENT audit file is searched. Rotated files (audit-*.log[.gz])
|
||||
// are deliberately out of scope: they need gzip support and a separate index,
|
||||
// and the UI is meant for recent-window triage ("what happened just now").
|
||||
const (
|
||||
auditQueryDefaultLimit = 100
|
||||
auditQueryMaxLimit = 500
|
||||
auditQueryMaxOffset = 10000
|
||||
|
||||
// auditQueryScanBudget bounds how much of the audit file a single query
|
||||
// reads. The tail is read when the file is larger, so the newest records
|
||||
// (what operators actually triage) are always covered and a multi-GB log
|
||||
// cannot stall the WS goroutine.
|
||||
auditQueryScanBudget = 32 << 20 // 32 MB
|
||||
)
|
||||
|
||||
// auditQueryRequest is the JSON body of the `query_audit` command.
|
||||
type auditQueryRequest struct {
|
||||
From string `json:"from"` // RFC3339 ("2026-10-18T10:00:00Z"), optional
|
||||
To string `json:"to"` // RFC3339, optional
|
||||
Event string `json:"event"` // exact ("web.login.failure"), prefix ("web." / "web.*"), or ""
|
||||
Actor string `json:"actor"` // exact, case-insensitive
|
||||
IP string `json:"ip"` // exact
|
||||
DeviceID string `json:"device_id"`
|
||||
Keyword string `json:"keyword"` // case-insensitive substring of the raw record
|
||||
WarnOnly bool `json:"warn_only"` // only level=warn records
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"` // skip the newest N matching records
|
||||
}
|
||||
|
||||
// auditQueryResult is the internal result of scanning the audit file.
|
||||
type auditQueryResult struct {
|
||||
Events []map[string]any
|
||||
Total int // matching records in the scanned window
|
||||
FileExists bool
|
||||
FileSize int64
|
||||
ScannedBytes int64
|
||||
Truncated bool // the file was larger than auditQueryScanBudget
|
||||
}
|
||||
|
||||
// handleQueryAudit implements the admin-only audit query command. The reply is
|
||||
// `{cmd:"audit_result", ok, events, total, ...}`; events are newest-first.
|
||||
func (h *wsHub) handleQueryAudit(c *wsClient, raw []byte) {
|
||||
const replyCmd = "audit_result"
|
||||
if !h.requireAdmin(c, raw, replyCmd) {
|
||||
return
|
||||
}
|
||||
var in auditQueryRequest
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid request"}))
|
||||
return
|
||||
}
|
||||
in.Limit = clampAuditLimit(in.Limit)
|
||||
in.Offset = clampAuditOffset(in.Offset)
|
||||
|
||||
path := h.log.AuditPath()
|
||||
if path == "" {
|
||||
h.audit(c, true, auditQuery, map[string]any{
|
||||
"ok": false,
|
||||
"reason": "audit_log_disabled",
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd, "ok": false,
|
||||
"msg": "Audit log file is disabled on this server (YAMA_AUDIT_LOG=off)",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := queryAuditLog(path, in)
|
||||
if err != nil {
|
||||
h.audit(c, true, auditQuery, map[string]any{
|
||||
"ok": false,
|
||||
"reason": "read_failed",
|
||||
"error": err.Error(),
|
||||
})
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd, "ok": false,
|
||||
"msg": "Failed to read audit log: " + err.Error(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// Record the query itself: an audit viewer is privileged, and reading the
|
||||
// trail is itself an auditable action. Filter terms are included so an
|
||||
// investigator can see what was searched for.
|
||||
h.audit(c, false, auditQuery, map[string]any{
|
||||
"ok": true,
|
||||
"event_filter": in.Event,
|
||||
"actor_filter": in.Actor,
|
||||
"ip_filter": in.IP,
|
||||
"device_filter": in.DeviceID,
|
||||
"keyword": in.Keyword,
|
||||
"warn_only": in.WarnOnly,
|
||||
"matches": res.Total,
|
||||
"returned": len(res.Events),
|
||||
"offset": in.Offset,
|
||||
})
|
||||
|
||||
c.queue(mustJSON(map[string]any{
|
||||
"cmd": replyCmd,
|
||||
"ok": true,
|
||||
"events": res.Events,
|
||||
"total": res.Total,
|
||||
"offset": in.Offset,
|
||||
"limit": in.Limit,
|
||||
"file": path,
|
||||
"file_exists": res.FileExists,
|
||||
"file_size": res.FileSize,
|
||||
"scanned_bytes": res.ScannedBytes,
|
||||
"truncated": res.Truncated,
|
||||
}))
|
||||
}
|
||||
|
||||
func clampAuditLimit(n int) int {
|
||||
if n <= 0 {
|
||||
return auditQueryDefaultLimit
|
||||
}
|
||||
if n > auditQueryMaxLimit {
|
||||
return auditQueryMaxLimit
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func clampAuditOffset(n int) int {
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
if n > auditQueryMaxOffset {
|
||||
return auditQueryMaxOffset
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// queryAuditLog scans path for audit records matching q. The returned events
|
||||
// are the newest limit records after skipping offset, in newest-first order.
|
||||
func queryAuditLog(path string, q auditQueryRequest) (auditQueryResult, error) {
|
||||
res := auditQueryResult{Events: []map[string]any{}}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// No audit record has been written yet (lumberjack creates the file
|
||||
// lazily). An empty result is correct, not an error.
|
||||
return res, nil
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.FileExists = true
|
||||
res.FileSize = info.Size()
|
||||
|
||||
// For very large files, scan only the tail; the newest matches live there.
|
||||
start := int64(0)
|
||||
if info.Size() > auditQueryScanBudget {
|
||||
start = info.Size() - auditQueryScanBudget
|
||||
if _, err := f.Seek(start, 0); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Truncated = true
|
||||
}
|
||||
|
||||
from, hasFrom := parseAuditTime(q.From)
|
||||
to, hasTo := parseAuditTime(q.To)
|
||||
|
||||
keep := q.Offset + q.Limit
|
||||
ring := make([]map[string]any, keep)
|
||||
filled := 0
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
// Sanitized values are capped at 4096 bytes each; a record with many
|
||||
// fields stays well under 1 MB. Allow headroom rather than aborting the
|
||||
// whole query on one long line.
|
||||
sc.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
|
||||
first := true
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if start > 0 && first {
|
||||
// Seeking into the middle of the file lands mid-line. Drop that
|
||||
// partial record.
|
||||
first = false
|
||||
continue
|
||||
}
|
||||
first = false
|
||||
res.ScannedBytes += int64(len(line)) + 1
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
continue // tolerate a torn line instead of failing the page
|
||||
}
|
||||
if _, ok := rec["event"].(string); !ok {
|
||||
continue
|
||||
}
|
||||
// Only audit records belong on the page. The dedicated file contains
|
||||
// nothing else, but a hand-edited or mixed file must not surface
|
||||
// ordinary operational lines here.
|
||||
if rec["audit"] != true {
|
||||
continue
|
||||
}
|
||||
if !auditRecordMatches(rec, line, q, from, hasFrom, to, hasTo) {
|
||||
continue
|
||||
}
|
||||
// Ring buffer of the last `keep` matches (file order is chronological).
|
||||
ring[res.Total%keep] = rec
|
||||
if filled < keep {
|
||||
filled++
|
||||
}
|
||||
res.Total++
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
// Newest first, skipping Offset. The match with 0-based index i lives at
|
||||
// ring[i%keep] as long as i >= Total-filled.
|
||||
for i := res.Total - 1 - q.Offset; i >= 0 && i >= res.Total-filled; i-- {
|
||||
res.Events = append(res.Events, ring[i%keep])
|
||||
if len(res.Events) >= q.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// auditRecordMatches applies the non-time and time filters to one record.
|
||||
// line is the raw JSON line, used for the keyword substring search.
|
||||
func auditRecordMatches(rec map[string]any, line string, q auditQueryRequest, from time.Time, hasFrom bool, to time.Time, hasTo bool) bool {
|
||||
if q.Event != "" && !auditEventFilterMatches(q.Event, auditStringField(rec, "event")) {
|
||||
return false
|
||||
}
|
||||
if q.Actor != "" && !strings.EqualFold(auditStringField(rec, "actor"), q.Actor) {
|
||||
return false
|
||||
}
|
||||
if q.IP != "" && auditStringField(rec, "ip") != q.IP {
|
||||
return false
|
||||
}
|
||||
if q.DeviceID != "" && auditStringField(rec, "device_id") != q.DeviceID {
|
||||
return false
|
||||
}
|
||||
if q.WarnOnly && auditStringField(rec, "level") != "warn" {
|
||||
return false
|
||||
}
|
||||
if q.Keyword != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(q.Keyword)) {
|
||||
return false
|
||||
}
|
||||
if hasFrom || hasTo {
|
||||
t, ok := parseAuditTime(auditStringField(rec, "time"))
|
||||
if !ok {
|
||||
return false // cannot prove it is in range
|
||||
}
|
||||
if hasFrom && t.Before(from) {
|
||||
return false
|
||||
}
|
||||
if hasTo && t.After(to) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// auditEventFilterMatches supports three forms so the UI stays simple:
|
||||
//
|
||||
// "web.login.failure" exact
|
||||
// "web.*" prefix (trailing "*")
|
||||
// "web." prefix (trailing ".")
|
||||
func auditEventFilterMatches(filter, event string) bool {
|
||||
switch {
|
||||
case strings.HasSuffix(filter, "*"):
|
||||
return strings.HasPrefix(event, strings.TrimSuffix(filter, "*"))
|
||||
case strings.HasSuffix(filter, "."):
|
||||
return strings.HasPrefix(event, filter)
|
||||
default:
|
||||
return event == filter
|
||||
}
|
||||
}
|
||||
|
||||
func auditStringField(rec map[string]any, key string) string {
|
||||
s, _ := rec[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// parseAuditTime accepts the formats operators and browsers realistically send.
|
||||
// Layouts without an explicit zone are parsed as UTC; the frontend always sends
|
||||
// Date.toISOString(), so the RFC3339 path is what production uses.
|
||||
func parseAuditTime(s string) (time.Time, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||
)
|
||||
|
||||
// newAuditTestHubWithoutAuditFile builds a hub whose logger has no dedicated
|
||||
// audit file, mirroring a server started with YAMA_AUDIT_LOG=off.
|
||||
func newAuditTestHubWithoutAuditFile(t *testing.T, dir string) (*wsHub, string) {
|
||||
t.Helper()
|
||||
lg := logger.New(logger.Config{
|
||||
Level: logger.LevelInfo,
|
||||
Console: false,
|
||||
File: filepath.Join(dir, "server.log"),
|
||||
})
|
||||
t.Cleanup(func() { _ = lg.Close() })
|
||||
wh := newWSHub(wsauth.New(), hub.New(), nil, lg)
|
||||
t.Cleanup(wh.stop)
|
||||
return wh, ""
|
||||
}
|
||||
|
||||
func readQueuedJSON(t *testing.T, c *wsClient) map[string]any {
|
||||
t.Helper()
|
||||
select {
|
||||
case m := <-c.send:
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal(m.data, &v); err != nil {
|
||||
t.Fatalf("queued reply is not JSON: %q: %v", m.data, err)
|
||||
}
|
||||
return v
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for a queued reply")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// writeAuditFixture writes JSON-lines audit records (oldest first) and returns
|
||||
// the file path. Times are UTC RFC3339 so the tests are timezone-independent.
|
||||
func writeAuditFixture(t *testing.T, records []map[string]any) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "audit.log")
|
||||
var b strings.Builder
|
||||
for _, rec := range records {
|
||||
line, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal fixture: %v", err)
|
||||
}
|
||||
b.Write(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func auditFixtureRecords() []map[string]any {
|
||||
at := func(hhmm string) string {
|
||||
tm, err := time.Parse("2006-01-02T15:04", "2026-10-18T"+hhmm)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return tm.Format(time.RFC3339)
|
||||
}
|
||||
return []map[string]any{
|
||||
{"level": "info", "time": at("10:00"), "audit": true, "event": "web.login.success", "actor": "admin", "ip": "203.0.113.1"},
|
||||
{"level": "warn", "time": at("10:01"), "audit": true, "event": "web.login.failure", "actor": "alice", "ip": "203.0.113.2", "reason": "invalid_credentials"},
|
||||
{"level": "info", "time": at("10:02"), "audit": true, "event": "remote.screen.start", "actor": "admin", "role": "admin", "ip": "203.0.113.1", "device_id": "dev1"},
|
||||
{"level": "info", "time": at("10:03"), "audit": true, "event": "admin.user.delete", "actor": "admin", "ip": "203.0.113.1", "target": "bob"},
|
||||
}
|
||||
}
|
||||
|
||||
func runAuditQuery(t *testing.T, path string, q auditQueryRequest) auditQueryResult {
|
||||
t.Helper()
|
||||
q.Limit = clampAuditLimit(q.Limit)
|
||||
q.Offset = clampAuditOffset(q.Offset)
|
||||
res, err := queryAuditLog(path, q)
|
||||
if err != nil {
|
||||
t.Fatalf("queryAuditLog: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func eventNames(res auditQueryResult) []string {
|
||||
out := make([]string, 0, len(res.Events))
|
||||
for _, e := range res.Events {
|
||||
out = append(out, auditStringField(e, "event"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAuditQueryNoFilterNewestFirst(t *testing.T) {
|
||||
path := writeAuditFixture(t, auditFixtureRecords())
|
||||
res := runAuditQuery(t, path, auditQueryRequest{Limit: 100})
|
||||
if res.Total != 4 || len(res.Events) != 4 {
|
||||
t.Fatalf("total=%d events=%d, want 4/4", res.Total, len(res.Events))
|
||||
}
|
||||
got := eventNames(res)
|
||||
want := []string{"admin.user.delete", "remote.screen.start", "web.login.failure", "web.login.success"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("events = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQueryEventFilters(t *testing.T) {
|
||||
path := writeAuditFixture(t, auditFixtureRecords())
|
||||
cases := []struct {
|
||||
filter string
|
||||
want []string
|
||||
}{
|
||||
{"web.", []string{"web.login.failure", "web.login.success"}},
|
||||
{"web.*", []string{"web.login.failure", "web.login.success"}},
|
||||
{"web.login.failure", []string{"web.login.failure"}},
|
||||
{"remote.*", []string{"remote.screen.start"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
res := runAuditQuery(t, path, auditQueryRequest{Event: tc.filter, Limit: 100})
|
||||
got := eventNames(res)
|
||||
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||
t.Errorf("filter %q → %v, want %v", tc.filter, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQueryFieldFilters(t *testing.T) {
|
||||
path := writeAuditFixture(t, auditFixtureRecords())
|
||||
from, _ := time.Parse(time.RFC3339, "2026-10-18T10:01:00Z")
|
||||
to, _ := time.Parse(time.RFC3339, "2026-10-18T10:02:00Z")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
q auditQueryRequest
|
||||
want []string
|
||||
}{
|
||||
{"actor case-insensitive", auditQueryRequest{Actor: "ALICE", Limit: 100}, []string{"web.login.failure"}},
|
||||
{"ip", auditQueryRequest{IP: "203.0.113.2", Limit: 100}, []string{"web.login.failure"}},
|
||||
{"device", auditQueryRequest{DeviceID: "dev1", Limit: 100}, []string{"remote.screen.start"}},
|
||||
{"warn only", auditQueryRequest{WarnOnly: true, Limit: 100}, []string{"web.login.failure"}},
|
||||
{"keyword target", auditQueryRequest{Keyword: "BOB", Limit: 100}, []string{"admin.user.delete"}},
|
||||
{"time range", auditQueryRequest{
|
||||
From: from.Format(time.RFC3339), To: to.Format(time.RFC3339), Limit: 100,
|
||||
}, []string{"remote.screen.start", "web.login.failure"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
res := runAuditQuery(t, path, tc.q)
|
||||
got := eventNames(res)
|
||||
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||
t.Errorf("%s → %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQueryOffsetAndLimit(t *testing.T) {
|
||||
path := writeAuditFixture(t, auditFixtureRecords())
|
||||
res := runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 1})
|
||||
if res.Total != 4 {
|
||||
t.Fatalf("total = %d, want 4", res.Total)
|
||||
}
|
||||
got := eventNames(res)
|
||||
want := []string{"remote.screen.start", "web.login.failure"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("events = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// Offsetting past the end yields no events but keeps the total.
|
||||
res = runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 4})
|
||||
if res.Total != 4 || len(res.Events) != 0 {
|
||||
t.Fatalf("offset past end: total=%d events=%v", res.Total, eventNames(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQuerySkipsNonAuditAndTornLines(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.log")
|
||||
content := `{"event":"device.online","actor":"x"}
|
||||
not json at all
|
||||
{"level":"info","time":"2026-10-18T10:00:00Z","audit":true,"event":"web.login.success","actor":"admin"}`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := runAuditQuery(t, path, auditQueryRequest{Limit: 100})
|
||||
if res.Total != 1 || len(res.Events) != 1 {
|
||||
t.Fatalf("total=%d events=%v, want the single valid record", res.Total, eventNames(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQueryMissingFile(t *testing.T) {
|
||||
res := runAuditQuery(t, filepath.Join(t.TempDir(), "does-not-exist.log"), auditQueryRequest{Limit: 10})
|
||||
if res.FileExists {
|
||||
t.Error("FileExists = true for a missing file")
|
||||
}
|
||||
if res.Total != 0 || len(res.Events) != 0 {
|
||||
t.Errorf("total=%d events=%v, want empty", res.Total, eventNames(res))
|
||||
}
|
||||
if res.Events == nil {
|
||||
t.Error("Events is nil; JSON would encode null instead of []")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQueryClampsPaging(t *testing.T) {
|
||||
if got := clampAuditLimit(0); got != auditQueryDefaultLimit {
|
||||
t.Errorf("limit(0) = %d, want default %d", got, auditQueryDefaultLimit)
|
||||
}
|
||||
if got := clampAuditLimit(-3); got != auditQueryDefaultLimit {
|
||||
t.Errorf("limit(-3) = %d, want default %d", got, auditQueryDefaultLimit)
|
||||
}
|
||||
if got := clampAuditLimit(9999); got != auditQueryMaxLimit {
|
||||
t.Errorf("limit(9999) = %d, want max %d", got, auditQueryMaxLimit)
|
||||
}
|
||||
if got := clampAuditLimit(50); got != 50 {
|
||||
t.Errorf("limit(50) = %d, want 50", got)
|
||||
}
|
||||
if got := clampAuditOffset(-5); got != 0 {
|
||||
t.Errorf("offset(-5) = %d, want 0", got)
|
||||
}
|
||||
if got := clampAuditOffset(1 << 30); got != auditQueryMaxOffset {
|
||||
t.Errorf("offset(huge) = %d, want max %d", got, auditQueryMaxOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAuditHandlerAdminOnly(t *testing.T) {
|
||||
wh, _, _ := auditTestHub(t)
|
||||
viewer, tok := auditTestClient(wh, "viewer")
|
||||
raw, _ := json.Marshal(map[string]any{"token": tok})
|
||||
wh.handleQueryAudit(viewer, raw)
|
||||
|
||||
msg := readQueuedJSON(t, viewer)
|
||||
if msg["cmd"] != "audit_result" || msg["ok"] != false {
|
||||
t.Fatalf("viewer reply = %+v", msg)
|
||||
}
|
||||
if _, ok := msg["events"]; ok {
|
||||
t.Error("denied viewer got events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAuditHandlerReturnsEvents(t *testing.T) {
|
||||
wh, _, _ := auditTestHub(t)
|
||||
// Two records written directly through the same logger the hub queries.
|
||||
wh.log.Audit("web.login.success", map[string]any{"actor": "admin", "ip": "203.0.113.1"})
|
||||
wh.log.Audit("remote.screen.start", map[string]any{"actor": "admin", "device_id": "dev1"})
|
||||
|
||||
admin, tok := auditTestClient(wh, "admin")
|
||||
raw, _ := json.Marshal(map[string]any{"token": tok, "limit": 10})
|
||||
wh.handleQueryAudit(admin, raw)
|
||||
|
||||
msg := readQueuedJSON(t, admin)
|
||||
if msg["cmd"] != "audit_result" || msg["ok"] != true {
|
||||
t.Fatalf("reply = %+v", msg)
|
||||
}
|
||||
events, ok := msg["events"].([]any)
|
||||
if !ok || len(events) < 2 {
|
||||
t.Fatalf("events = %#v, want at least the two written records", msg["events"])
|
||||
}
|
||||
// requireAuth writes a web.session.resume record for this same request, so
|
||||
// that one legitimately appears newer. Assert the two written records are
|
||||
// present and still newest-first relative to each other.
|
||||
var names []string
|
||||
for _, e := range events {
|
||||
if m, ok := e.(map[string]any); ok {
|
||||
names = append(names, auditStringField(m, "event"))
|
||||
}
|
||||
}
|
||||
joined := strings.Join(names, ",")
|
||||
if !strings.Contains(joined, "remote.screen.start") || !strings.Contains(joined, "web.login.success") {
|
||||
t.Fatalf("events = %v, want both written records", names)
|
||||
}
|
||||
if strings.Index(joined, "remote.screen.start") > strings.Index(joined, "web.login.success") {
|
||||
t.Fatalf("events = %v, want newest-first order", names)
|
||||
}
|
||||
if msg["truncated"] != false || msg["file_exists"] != true {
|
||||
t.Errorf("meta = %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAuditHandlerDisabledFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wh, _ := newAuditTestHubWithoutAuditFile(t, dir)
|
||||
admin, tok := auditTestClient(wh, "admin")
|
||||
raw, _ := json.Marshal(map[string]any{"token": tok})
|
||||
wh.handleQueryAudit(admin, raw)
|
||||
|
||||
msg := readQueuedJSON(t, admin)
|
||||
if msg["ok"] != false {
|
||||
t.Fatalf("reply = %+v, want ok:false when the audit file is disabled", msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||
)
|
||||
|
||||
// auditTestHub builds a wsHub whose logger writes both the main log and the
|
||||
// dedicated audit log to a temp directory.
|
||||
func auditTestHub(t *testing.T) (wh *wsHub, devices *hub.Hub, auditPath string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
auditPath = filepath.Join(dir, "audit.log")
|
||||
lg := logger.New(logger.Config{
|
||||
Level: logger.LevelInfo,
|
||||
Console: false,
|
||||
File: filepath.Join(dir, "server.log"),
|
||||
AuditFile: auditPath,
|
||||
})
|
||||
t.Cleanup(func() { _ = lg.Close() })
|
||||
|
||||
devices = hub.New()
|
||||
wh = newWSHub(wsauth.New(), devices, nil, lg)
|
||||
t.Cleanup(wh.stop)
|
||||
return wh, devices, auditPath
|
||||
}
|
||||
|
||||
func auditTestClient(wh *wsHub, role string) (*wsClient, string) {
|
||||
tok, err := wh.auth.IssueToken("tester", role)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
c := &wsClient{
|
||||
send: make(chan wsMsg, 64),
|
||||
closed: make(chan struct{}),
|
||||
id: newConnID(),
|
||||
addr: "203.0.113.5",
|
||||
peerAddr: "203.0.113.5",
|
||||
connectedAt: time.Now(),
|
||||
watchIDs: make(map[string]struct{}),
|
||||
watchStartedAt: make(map[string]time.Time),
|
||||
keyframeAt: make(map[string]int64),
|
||||
}
|
||||
wh.register(c)
|
||||
return c, tok
|
||||
}
|
||||
|
||||
func readAuditEvents(t *testing.T, path string) []map[string]any {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open audit log: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var out []map[string]any
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||
t.Fatalf("bad audit line %q: %v", line, err)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
t.Fatalf("scan audit log: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findAuditEvent(t *testing.T, path, event string) map[string]any {
|
||||
t.Helper()
|
||||
for _, m := range readAuditEvents(t, path) {
|
||||
if m["event"] == event {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("audit event %q not found in %v", event, readAuditEvents(t, path))
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLoginFailureIsAudited(t *testing.T) {
|
||||
wh, _, auditPath := auditTestHub(t)
|
||||
wh.auth.AddAdminFromPlainPassword("admin", "hunter2")
|
||||
c, _ := auditTestClient(wh, "viewer")
|
||||
|
||||
nonce, err := wsauth.NewNonce()
|
||||
if err != nil {
|
||||
t.Fatalf("NewNonce: %v", err)
|
||||
}
|
||||
c.nonce = nonce
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"username": "admin",
|
||||
"response": "not-the-right-response",
|
||||
"nonce": nonce,
|
||||
})
|
||||
wh.handleLogin(c, raw)
|
||||
|
||||
rec := findAuditEvent(t, auditPath, auditLoginFail)
|
||||
if rec["reason"] != "invalid_credentials" {
|
||||
t.Errorf("reason = %v", rec["reason"])
|
||||
}
|
||||
if rec["username"] != "admin" {
|
||||
t.Errorf("username = %v", rec["username"])
|
||||
}
|
||||
if rec["user_exists"] != true {
|
||||
t.Errorf("user_exists = %v, want true", rec["user_exists"])
|
||||
}
|
||||
if rec["ip"] != "203.0.113.5" {
|
||||
t.Errorf("ip = %v", rec["ip"])
|
||||
}
|
||||
if _, ok := rec["response"]; ok {
|
||||
t.Error("audit record leaked the login response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDenialIsAudited(t *testing.T) {
|
||||
wh, _, auditPath := auditTestHub(t)
|
||||
c, tok := auditTestClient(wh, "viewer")
|
||||
raw, _ := json.Marshal(map[string]any{"token": tok})
|
||||
|
||||
if wh.requireAdmin(c, raw, "delete_device_result") {
|
||||
t.Fatal("viewer passed requireAdmin")
|
||||
}
|
||||
rec := findAuditEvent(t, auditPath, auditDenied)
|
||||
if rec["operation"] != "delete_device" {
|
||||
t.Errorf("operation = %v", rec["operation"])
|
||||
}
|
||||
if rec["role"] != "viewer" || rec["actor"] != "tester" {
|
||||
t.Errorf("identity fields = %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenStartAndOfflineStopAreAudited(t *testing.T) {
|
||||
wh, devices, auditPath := auditTestHub(t)
|
||||
devices.SetSender(func(*connection.Context, []byte) error { return nil })
|
||||
hctx := &connection.Context{RemoteAddr: "10.0.0.9:1234"}
|
||||
devices.Register(&hub.Device{ID: "dev1", Name: "Dev"}, hctx)
|
||||
|
||||
c, tok := auditTestClient(wh, "admin")
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"token": tok, "id": "dev1", "multiWatch": true, "req": "1",
|
||||
})
|
||||
wh.handleConnect(c, raw)
|
||||
|
||||
start := findAuditEvent(t, auditPath, auditScreenStart)
|
||||
if start["device_id"] != "dev1" || start["mode"] != "started" {
|
||||
t.Errorf("screen start = %+v", start)
|
||||
}
|
||||
|
||||
wh.OnDeviceOffline("dev1")
|
||||
|
||||
stop := findAuditEvent(t, auditPath, auditScreenStop)
|
||||
if stop["device_id"] != "dev1" || stop["reason"] != "device_offline" {
|
||||
t.Errorf("screen stop = %+v", stop)
|
||||
}
|
||||
if _, ok := stop["duration_ms"]; !ok {
|
||||
t.Error("screen stop has no duration_ms")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownCommandRecordedOnce(t *testing.T) {
|
||||
wh, _, auditPath := auditTestHub(t)
|
||||
c, _ := auditTestClient(wh, "viewer")
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
wh.dispatch(c, "definitely_not_a_command", []byte(`{}`))
|
||||
}
|
||||
count := 0
|
||||
for _, m := range readAuditEvents(t, auditPath) {
|
||||
if m["event"] == auditUnknownCmd {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("unknown-command audit records = %d, want 1", count)
|
||||
}
|
||||
if c.statUnknownCmd != 5 {
|
||||
t.Fatalf("statUnknownCmd = %d, want 5", c.statUnknownCmd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||
)
|
||||
|
||||
// deleteTestHub builds the minimal wsHub wiring handleDeleteDevice needs.
|
||||
func deleteTestHub(t *testing.T) (*wsHub, *hub.Hub, *devicehistory.Store) {
|
||||
t.Helper()
|
||||
h := hub.New()
|
||||
hist, err := devicehistory.Open(filepath.Join(t.TempDir(), "devices.json"), 100, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open history: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = hist.Close() })
|
||||
h.Subscribe(hist)
|
||||
|
||||
auth := wsauth.New()
|
||||
wh := &wsHub{
|
||||
auth: auth,
|
||||
devices: h,
|
||||
history: hist,
|
||||
log: logger.New(logger.Config{}),
|
||||
clients: make(map[*wsClient]struct{}),
|
||||
}
|
||||
return wh, h, hist
|
||||
}
|
||||
|
||||
func deleteTestClient(wh *wsHub, role string) (*wsClient, string) {
|
||||
tok, err := wh.auth.IssueToken("tester", role)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
c := &wsClient{send: make(chan wsMsg, 8), closed: make(chan struct{})}
|
||||
wh.register(c)
|
||||
return c, tok
|
||||
}
|
||||
|
||||
func deleteReply(t *testing.T, c *wsClient) map[string]any {
|
||||
t.Helper()
|
||||
for {
|
||||
select {
|
||||
case m := <-c.send:
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(m.data, &out); err != nil {
|
||||
t.Fatalf("decode reply: %v", err)
|
||||
}
|
||||
if out["cmd"] == "delete_device_result" {
|
||||
return out
|
||||
}
|
||||
default:
|
||||
t.Fatal("no delete_device_result reply")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDeviceOfflineRemovesHistoryRecord(t *testing.T) {
|
||||
wh, h, hist := deleteTestHub(t)
|
||||
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
|
||||
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
|
||||
h.Unregister("gone")
|
||||
|
||||
c, tok := deleteTestClient(wh, "admin")
|
||||
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
|
||||
wh.handleDeleteDevice(c, raw)
|
||||
|
||||
rep := deleteReply(t, c)
|
||||
if rep["ok"] != true || rep["mode"] != "removed" {
|
||||
t.Fatalf("reply = %+v, want ok/removed", rep)
|
||||
}
|
||||
if hist.Remove("gone") {
|
||||
t.Fatal("history record still present after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDeviceOnlineSendsUninstall(t *testing.T) {
|
||||
wh, h, hist := deleteTestHub(t)
|
||||
var sent []byte
|
||||
h.SetSender(func(_ *connection.Context, data []byte) error {
|
||||
sent = append([]byte(nil), data...)
|
||||
return nil
|
||||
})
|
||||
h.Register(&hub.Device{ID: "live", Name: "Live"},
|
||||
&connection.Context{RemoteAddr: "10.0.0.2:1000"})
|
||||
|
||||
c, tok := deleteTestClient(wh, "admin")
|
||||
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "live"})
|
||||
wh.handleDeleteDevice(c, raw)
|
||||
|
||||
rep := deleteReply(t, c)
|
||||
if rep["ok"] != true || rep["mode"] != "uninstall" {
|
||||
t.Fatalf("reply = %+v, want ok/uninstall", rep)
|
||||
}
|
||||
if len(sent) != 1 || sent[0] != protocol.CommandUninstall {
|
||||
t.Fatalf("sent = %v, want single %d byte", sent, protocol.CommandUninstall)
|
||||
}
|
||||
// The history record is dropped along with the uninstall, so the device
|
||||
// does not reappear as a dead offline entry after it disconnects.
|
||||
if hist.Remove("live") {
|
||||
t.Fatal("online device's history record survived the uninstall")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDeviceRequiresAdmin(t *testing.T) {
|
||||
wh, h, hist := deleteTestHub(t)
|
||||
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
|
||||
&connection.Context{RemoteAddr: "10.0.0.3:1000"})
|
||||
h.Unregister("gone")
|
||||
|
||||
c, tok := deleteTestClient(wh, "viewer")
|
||||
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
|
||||
wh.handleDeleteDevice(c, raw)
|
||||
|
||||
rep := deleteReply(t, c)
|
||||
if rep["ok"] != false {
|
||||
t.Fatalf("reply = %+v, want ok:false", rep)
|
||||
}
|
||||
if !hist.Remove("gone") {
|
||||
t.Fatal("viewer delete removed the history record")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
|
||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||
)
|
||||
|
||||
func TestListDevicesWithOffline(t *testing.T) {
|
||||
h := hub.New()
|
||||
hist, err := devicehistory.Open(filepath.Join(t.TempDir(), "devices.json"), 100, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open history: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = hist.Close() })
|
||||
h.Subscribe(hist)
|
||||
|
||||
h.Register(&hub.Device{ID: "online", Name: "Online"},
|
||||
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
|
||||
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
|
||||
&connection.Context{RemoteAddr: "10.0.0.2:1000"})
|
||||
h.Unregister("gone")
|
||||
|
||||
wh := &wsHub{devices: h, history: hist}
|
||||
got := wh.listDevicesWithOffline()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("devices = %d, want 2 (1 online + 1 offline)", len(got))
|
||||
}
|
||||
if got[0].ID != "online" || !got[0].Online {
|
||||
t.Fatalf("first = %+v, want online device", got[0])
|
||||
}
|
||||
if got[1].ID != "gone" || got[1].Online {
|
||||
t.Fatalf("second = %+v, want offline device", got[1])
|
||||
}
|
||||
if got[1].LastSeen == 0 {
|
||||
t.Fatal("offline device missing last_seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDevicesWithoutHistoryIsOnlineOnly(t *testing.T) {
|
||||
h := hub.New()
|
||||
h.Register(&hub.Device{ID: "online", Name: "Online"},
|
||||
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
|
||||
|
||||
wh := &wsHub{devices: h}
|
||||
got := wh.listDevicesWithOffline()
|
||||
if len(got) != 1 || got[0].ID != "online" {
|
||||
t.Fatalf("devices = %+v, want just the online device", got)
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -16,6 +16,7 @@ import (
|
||||
"unicode"
|
||||
"unicode/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
@@ -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{
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
+690
-71
File diff suppressed because it is too large.
Load diff
+2323
-82
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user