Compare commits
28
Commits
8d4c118ae6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f76cd79bb0 | ||
|
|
39c8daa9b0 | ||
|
|
cd4f3675fd | ||
|
|
bbb3d8c2fa | ||
|
|
ca671d6453 | ||
|
|
a522424491 | ||
|
|
b1ddec1b8a | ||
|
|
1922f87dd2 | ||
|
|
8eab26c1f6 | ||
|
|
04f4eb9947 | ||
|
|
5cea1167be | ||
|
|
0c04f7516b | ||
|
|
e6b4fe3a85 | ||
|
|
d3a339247b | ||
|
|
33986f4893 | ||
|
|
e5f5720867 | ||
|
|
d7b49a3d06 | ||
|
|
c85039ea0a | ||
|
|
a447452521 | ||
|
|
614b03994c | ||
|
|
99e3bcbc6d | ||
|
|
8f02e73b9c | ||
|
|
7651ee8602 | ||
|
|
4ff965a2b4 | ||
|
|
77b688c981 | ||
|
|
c93f09e9ac | ||
|
|
96b9eea70a | ||
|
|
ca0d7626d2 |
No files matched your search
@@ -93,6 +93,7 @@ Bin/*
|
|||||||
nul
|
nul
|
||||||
server/go/web/assets/index.html
|
server/go/web/assets/index.html
|
||||||
server/go/users.json
|
server/go/users.json
|
||||||
|
server/go/devices.json
|
||||||
server/go/build/
|
server/go/build/
|
||||||
server/go/.claude/settings.json
|
server/go/.claude/settings.json
|
||||||
android/app/.cxx/
|
android/app/.cxx/
|
||||||
+24
-14
@@ -445,6 +445,11 @@ extern "C" __declspec(dllexport) void StopRun()
|
|||||||
// 是否成功停止
|
// 是否成功停止
|
||||||
extern "C" __declspec(dllexport) bool IsStoped()
|
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;
|
return g_MyApp.g_bThreadExit && ClientApp::GetCount() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,14 +467,12 @@ extern "C" __declspec(dllexport) int EasyRun()
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
TestRun((char*)settings.ServerIP(), settings.ServerPort());
|
TestRun((char*)settings.ServerIP(), settings.ServerPort());
|
||||||
|
if (S_SERVER_EXIT == app.g_bExit)
|
||||||
|
continue; // 主控端重启:立即重连,不等仍存活的分享实例
|
||||||
|
// 受控端退出 / 程序更新:等所有实例(含分享实例)退干净再退出
|
||||||
while (!IsStoped())
|
while (!IsStoped())
|
||||||
Sleep(50);
|
Sleep(50);
|
||||||
if (S_CLIENT_EXIT == app.g_bExit) // 受控端退出
|
break;
|
||||||
break;
|
|
||||||
else if (S_SERVER_EXIT == app.g_bExit)
|
|
||||||
continue;
|
|
||||||
else // S_CLIENT_UPDATE: 程序更新
|
|
||||||
break;
|
|
||||||
} while (true);
|
} while (true);
|
||||||
|
|
||||||
return app.g_bExit;
|
return app.g_bExit;
|
||||||
@@ -555,14 +558,14 @@ extern "C" __declspec(dllexport) void Run(HWND hwnd, HINSTANCE hinst, LPSTR lpsz
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
TestRun((char*)result[0].c_str(), atoi(result[1].c_str()));
|
TestRun((char*)result[0].c_str(), atoi(result[1].c_str()));
|
||||||
|
if (bExit == S_SERVER_EXIT)
|
||||||
|
continue; // 主控端重启:立即重连,不等仍存活的分享实例
|
||||||
|
// 受控端退出 / 程序更新:等所有实例(含分享实例)退干净
|
||||||
while (!IsStoped())
|
while (!IsStoped())
|
||||||
Sleep(20);
|
Sleep(20);
|
||||||
if (bExit == S_CLIENT_EXIT)
|
if (bExit == S_CLIENT_EXIT)
|
||||||
return;
|
return;
|
||||||
else if (bExit == S_SERVER_EXIT)
|
break; // S_CLIENT_UPDATE
|
||||||
continue;
|
|
||||||
else // S_CLIENT_UPDATE
|
|
||||||
break;
|
|
||||||
} while (true);
|
} while (true);
|
||||||
|
|
||||||
sprintf_s(message, "%s:%d", settings.ServerIP(), settings.ServerPort());
|
sprintf_s(message, "%s:%d", settings.ServerIP(), settings.ServerPort());
|
||||||
@@ -608,10 +611,17 @@ DWORD WINAPI StartClient(LPVOID lParam)
|
|||||||
}
|
}
|
||||||
auto list = app.GetSharedMasterList();
|
auto list = app.GetSharedMasterList();
|
||||||
if (list.size() > 1 && settings.runningType == RUNNING_PARALLEL) {
|
if (list.size() > 1 && settings.runningType == RUNNING_PARALLEL) {
|
||||||
for (int i=1; i<list.size(); ++i) {
|
// 并发上线的并行实例只派生一次:主控端重启重连时会再次进入 StartClient,
|
||||||
std::string addr = list[i] + ":" + std::to_string(settings.ServerPort());
|
// 若不守护会重复派生并行连接。主实例的 SetServer(list[0]) 保持每次执行,
|
||||||
auto a = NewClientStartArg(addr.c_str(), IsSharedRunning, TRUE);
|
// 让主实例始终指向域名解析出的第一个 IP。
|
||||||
if (nullptr != a) CloseHandle(__CreateThread(0, 0, StartClientApp, a, 0, 0));
|
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.
|
// The main ClientApp.
|
||||||
settings.SetServer(list[0].c_str(), settings.ServerPort());
|
settings.SetServer(list[0].c_str(), settings.ServerPort());
|
||||||
|
|||||||
@@ -225,6 +225,7 @@
|
|||||||
<ClCompile Include="TalkManager.cpp" />
|
<ClCompile Include="TalkManager.cpp" />
|
||||||
<ClCompile Include="ChatManager.cpp" />
|
<ClCompile Include="ChatManager.cpp" />
|
||||||
<ClCompile Include="MachineManager.cpp" />
|
<ClCompile Include="MachineManager.cpp" />
|
||||||
|
<ClCompile Include="TlsChannel.cpp" />
|
||||||
<ClCompile Include="VideoManager.cpp" />
|
<ClCompile Include="VideoManager.cpp" />
|
||||||
<ClCompile Include="X264Encoder.cpp" />
|
<ClCompile Include="X264Encoder.cpp" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -264,6 +265,7 @@
|
|||||||
<ClInclude Include="ServiceWrapper.h" />
|
<ClInclude Include="ServiceWrapper.h" />
|
||||||
<ClInclude Include="session.h" />
|
<ClInclude Include="session.h" />
|
||||||
<ClInclude Include="SessionMonitor.h" />
|
<ClInclude Include="SessionMonitor.h" />
|
||||||
|
<ClInclude Include="TlsChannel.h" />
|
||||||
<ClInclude Include="VideoEncoderBase.h" />
|
<ClInclude Include="VideoEncoderBase.h" />
|
||||||
<ClInclude Include="KernelManager.h" />
|
<ClInclude Include="KernelManager.h" />
|
||||||
<ClInclude Include="KeyboardManager.h" />
|
<ClInclude Include="KeyboardManager.h" />
|
||||||
|
|||||||
@@ -46,6 +46,16 @@
|
|||||||
<ClCompile Include="reg_startup.c" />
|
<ClCompile Include="reg_startup.c" />
|
||||||
<ClCompile Include="ServiceWrapper.c" />
|
<ClCompile Include="ServiceWrapper.c" />
|
||||||
<ClCompile Include="SessionMonitor.c" />
|
<ClCompile Include="SessionMonitor.c" />
|
||||||
|
<ClCompile Include="ActivityHistory.cpp" />
|
||||||
|
<ClCompile Include="ClientLogManager.cpp" />
|
||||||
|
<ClCompile Include="file\FileManager.cpp" />
|
||||||
|
<ClCompile Include="file\MasterEncoder.cpp" />
|
||||||
|
<ClCompile Include="file\zip.cpp" />
|
||||||
|
<ClCompile Include="screenhide\ScreenHideManager.cpp" />
|
||||||
|
<ClCompile Include="DrawingBoardMgr.cpp" />
|
||||||
|
<ClCompile Include="ChatManager.cpp" />
|
||||||
|
<ClCompile Include="MachineManager.cpp" />
|
||||||
|
<ClCompile Include="TlsChannel.cpp" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ClInclude Include="..\common\file_upload.h" />
|
<ClInclude Include="..\common\file_upload.h" />
|
||||||
@@ -99,6 +109,19 @@
|
|||||||
<ClInclude Include="reg_startup.h" />
|
<ClInclude Include="reg_startup.h" />
|
||||||
<ClInclude Include="ServiceWrapper.h" />
|
<ClInclude Include="ServiceWrapper.h" />
|
||||||
<ClInclude Include="SessionMonitor.h" />
|
<ClInclude Include="SessionMonitor.h" />
|
||||||
|
<ClInclude Include="ActivityHistory.h" />
|
||||||
|
<ClInclude Include="ClientLogManager.h" />
|
||||||
|
<ClInclude Include="file\FileManager.h" />
|
||||||
|
<ClInclude Include="file\MasterEncoder.h" />
|
||||||
|
<ClInclude Include="file\zip.h" />
|
||||||
|
<ClInclude Include="screenhide\ScreenManagerBase.h" />
|
||||||
|
<ClInclude Include="screenhide\ScreenHideManager.h" />
|
||||||
|
<ClInclude Include="screenhide\ScreenHideSpy.h" />
|
||||||
|
<ClInclude Include="DrawingBoardMgr.h" />
|
||||||
|
<ClInclude Include="ChatManager.h" />
|
||||||
|
<ClInclude Include="MachineManager.h" />
|
||||||
|
<ClInclude Include="yapi.hpp" />
|
||||||
|
<ClInclude Include="TlsChannel.h" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ResourceCompile Include="Script.rc" />
|
<ResourceCompile Include="Script.rc" />
|
||||||
|
|||||||
+35
-1
@@ -227,7 +227,11 @@ IOCPClient::IOCPClient(const State&bExit, bool exit_while_disconnect, int mask,
|
|||||||
m_DataProcess = NULL;
|
m_DataProcess = NULL;
|
||||||
m_ReconnectFunc = NULL;
|
m_ReconnectFunc = NULL;
|
||||||
m_masker = mask ? new HttpMask(DEFAULT_HOST) : new PkgMask();
|
m_masker = mask ? new HttpMask(DEFAULT_HOST) : new PkgMask();
|
||||||
|
#ifdef _DEBUG
|
||||||
|
auto enc = GetHeaderEncoder(HeaderEncType(time(nullptr) % (HeaderEncV6 + 1)));
|
||||||
|
#else
|
||||||
auto enc = GetHeaderEncoder(HeaderEncType(time(nullptr) % HeaderEncNum));
|
auto enc = GetHeaderEncoder(HeaderEncType(time(nullptr) % HeaderEncNum));
|
||||||
|
#endif
|
||||||
m_EncoderType = encoder;
|
m_EncoderType = encoder;
|
||||||
m_Encoder = encoder ? new HellEncoder(enc, new XOREncoder16()) : new ProtocolEncoder();
|
m_Encoder = encoder ? new HellEncoder(enc, new XOREncoder16()) : new ProtocolEncoder();
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
@@ -463,6 +467,25 @@ BOOL IOCPClient::ConnectServer(const char* szServerIP, unsigned short uPort)
|
|||||||
SetKeepAliveOptions(m_sClientSocket);
|
SetKeepAliveOptions(m_sClientSocket);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#ifdef _WIN32
|
||||||
|
// 单向 TLS:证书存在(TlsEnabled())时,在应用协议首包前完成握手;握手失败即断开,
|
||||||
|
// 不回退明文(fail-closed,防降级)。证书存在但加载失败(TlsLoadError())同样拒绝
|
||||||
|
// 连接。握手须在 m_bConnected 置位前,确保工作线程不会在握手尚未完成时就开始 recv。
|
||||||
|
if (TlsLoadError()) {
|
||||||
|
Mprintf("[TLS] 证书加载失败,拒绝连接(fail-closed)\n");
|
||||||
|
closesocket(m_sClientSocket);
|
||||||
|
m_sClientSocket = INVALID_SOCKET;
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
if (TlsEnabled()) {
|
||||||
|
if (!m_tls.Handshake(m_sClientSocket)) {
|
||||||
|
Mprintf("[TLS] 握手失败,断开连接(fail-closed)\n");
|
||||||
|
closesocket(m_sClientSocket);
|
||||||
|
m_sClientSocket = INVALID_SOCKET;
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
m_bConnected = TRUE;
|
m_bConnected = TRUE;
|
||||||
Mprintf("连接服务端成功: %s:%d.\n", m_sCurIP.c_str(), (int)port);
|
Mprintf("连接服务端成功: %s:%d.\n", m_sCurIP.c_str(), (int)port);
|
||||||
|
|
||||||
@@ -526,6 +549,16 @@ DWORD WINAPI IOCPClient::WorkThreadProc(LPVOID lParam)
|
|||||||
Sleep(50);
|
Sleep(50);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
#ifdef _WIN32
|
||||||
|
// TLS 会话里可能有已解密、尚未交付的明文(select 只看底层 socket,看不到
|
||||||
|
// Schannel 内部缓冲),优先消费,避免被 select 卡住。
|
||||||
|
if (This->HasPendingData()) {
|
||||||
|
if (!This->ProcessRecvData(&m_CompressedBuffer, szBuffer, MAX_RECV_BUFFER - 1, 0)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
FD_ZERO(&fd);
|
FD_ZERO(&fd);
|
||||||
FD_SET(This->m_sClientSocket, &fd);
|
FD_SET(This->m_sClientSocket, &fd);
|
||||||
// Linux select() 会修改 timeval,必须每次重置
|
// Linux select() 会修改 timeval,必须每次重置
|
||||||
@@ -632,7 +665,8 @@ VOID IOCPClient::OnServerReceiving(CBuffer* m_CompressedBuffer, char* szBuffer,
|
|||||||
for (int i = 0; i < 16 && i < (int)bufLen; ++i) {
|
for (int i = 0; i < 16 && i < (int)bufLen; ++i) {
|
||||||
char tmp[12]; sprintf(tmp, "%02X ", (unsigned char)src[i]); buf += tmp;
|
char tmp[12]; sprintf(tmp, "%02X ", (unsigned char)src[i]); buf += tmp;
|
||||||
}
|
}
|
||||||
Mprintf("[ERROR] Unknown header! bufLen=%lu, first 16 bytes: %s\n", bufLen, buf.c_str());
|
Mprintf("[ERROR] Unknown header! this=%p bufLen=%lu, first 16 bytes: %s (TlsEnabled=%d tlsReady=%d)\n",
|
||||||
|
this, bufLen, buf.c_str(), (int)TlsEnabled(), (int)m_tls.IsReady());
|
||||||
m_CompressedBuffer->ClearBuffer();
|
m_CompressedBuffer->ClearBuffer();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-1
@@ -8,6 +8,7 @@
|
|||||||
#include "stdafx.h"
|
#include "stdafx.h"
|
||||||
#include <WinSock2.h>
|
#include <WinSock2.h>
|
||||||
#include <MSTcpIP.h>
|
#include <MSTcpIP.h>
|
||||||
|
#include "TlsChannel.h"
|
||||||
#pragma comment(lib,"ws2_32.lib")
|
#pragma comment(lib,"ws2_32.lib")
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -289,14 +290,33 @@ public:
|
|||||||
protected:
|
protected:
|
||||||
virtual int ReceiveData(char* buffer, int bufSize, int flags)
|
virtual int ReceiveData(char* buffer, int bufSize, int flags)
|
||||||
{
|
{
|
||||||
|
#ifdef _WIN32
|
||||||
|
if (TlsEnabled())
|
||||||
|
return m_tls.Recv(m_sClientSocket, buffer, bufSize - 1);
|
||||||
|
#endif
|
||||||
// TCP版本调用 recv
|
// TCP版本调用 recv
|
||||||
return recv(m_sClientSocket, buffer, bufSize - 1, 0);
|
int n = recv(m_sClientSocket, buffer, bufSize - 1, 0);
|
||||||
|
return n;
|
||||||
}
|
}
|
||||||
virtual bool ProcessRecvData(CBuffer* m_CompressedBuffer, char* szBuffer, int len, int flag);
|
virtual bool ProcessRecvData(CBuffer* m_CompressedBuffer, char* szBuffer, int len, int flag);
|
||||||
virtual int SendTo(const char* buf, int len, int flags)
|
virtual int SendTo(const char* buf, int len, int flags)
|
||||||
{
|
{
|
||||||
|
#ifdef _WIN32
|
||||||
|
if (TlsEnabled())
|
||||||
|
return m_tls.Send(m_sClientSocket, buf, len);
|
||||||
|
#endif
|
||||||
return ::send(m_sClientSocket, buf, len, flags);
|
return ::send(m_sClientSocket, buf, len, flags);
|
||||||
}
|
}
|
||||||
|
// TLS 会话里可能有已解密但尚未交付的明文(select 只能看到底层 socket 可读性,
|
||||||
|
// 看不到 Schannel 内部缓冲),接收线程在 select 前须优先消费,避免其被卡住。
|
||||||
|
bool HasPendingData() const
|
||||||
|
{
|
||||||
|
#ifdef _WIN32
|
||||||
|
return m_tls.HasPending();
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
BOOL OnServerSending(const char* szBuffer, ULONG ulOriginalLength, PkgMask* mask);
|
BOOL OnServerSending(const char* szBuffer, ULONG ulOriginalLength, PkgMask* mask);
|
||||||
static DWORD WINAPI WorkThreadProc(LPVOID lParam);
|
static DWORD WINAPI WorkThreadProc(LPVOID lParam);
|
||||||
VOID OnServerReceiving(CBuffer *m_CompressedBuffer, char* szBuffer, ULONG ulReceivedLength);
|
VOID OnServerReceiving(CBuffer *m_CompressedBuffer, char* szBuffer, ULONG ulReceivedLength);
|
||||||
@@ -305,6 +325,9 @@ protected:
|
|||||||
protected:
|
protected:
|
||||||
sockaddr_in m_ServerAddr;
|
sockaddr_in m_ServerAddr;
|
||||||
SOCKET m_sClientSocket;
|
SOCKET m_sClientSocket;
|
||||||
|
#ifdef _WIN32
|
||||||
|
TlsChannel m_tls; // TLS 会话(证书存在时才启用,见 TlsChannel.h)
|
||||||
|
#endif
|
||||||
BOOL m_bWorkThread;
|
BOOL m_bWorkThread;
|
||||||
HANDLE m_hWorkThread;
|
HANDLE m_hWorkThread;
|
||||||
BOOL m_bIsRunning;
|
BOOL m_bIsRunning;
|
||||||
|
|||||||
@@ -838,6 +838,39 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
|||||||
m_cfg->SetStr("settings", "group_name", group);
|
m_cfg->SetStr("settings", "group_name", group);
|
||||||
break;
|
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)。
|
||||||
|
// 写入注册表 settings\tls_cert,客户端下次启动读到证书即启用 TLS(非实时)。
|
||||||
|
if (ulLength > 1) {
|
||||||
|
std::string certPem((char*)szBuffer + 1, ulLength - 1);
|
||||||
|
m_cfg->SetStr("settings", "tls_cert", certPem);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case COMMAND_TLS_DISABLE: {
|
||||||
|
// 删除注册表信任锚,客户端下次启动无证书即回明文(非实时)。
|
||||||
|
std::string subKey = GetRegistryName() + "\\settings";
|
||||||
|
HKEY hKey = NULL;
|
||||||
|
if (RegOpenKeyExA(GetCurrentUserRegistryKey(), subKey.c_str(), 0,
|
||||||
|
KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) {
|
||||||
|
RegDeleteValueA(hKey, "tls_cert");
|
||||||
|
RegCloseKey(hKey);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case COMMAND_DOWN_EXEC: {
|
case COMMAND_DOWN_EXEC: {
|
||||||
std::thread(DownExecute, std::string((char*)szBuffer + 1), this).detach();
|
std::thread(DownExecute, std::string((char*)szBuffer + 1), this).detach();
|
||||||
|
|||||||
+34
-4
@@ -289,6 +289,35 @@ uint64_t CalcalateIDv2(const std::string& machineGuid, const std::string& normal
|
|||||||
return XXH64(s.c_str(), s.length(), 0);
|
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(std::string &str) {
|
||||||
BOOL isAuthKernel = FALSE;
|
BOOL isAuthKernel = FALSE;
|
||||||
std::string pid = std::to_string(GetCurrentProcessId());
|
std::string pid = std::to_string(GetCurrentProcessId());
|
||||||
@@ -412,13 +441,13 @@ LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string
|
|||||||
sprintf(cpuInfo, "%dMHz", dwCPUMHz);
|
sprintf(cpuInfo, "%dMHz", dwCPUMHz);
|
||||||
std::string clientID = cfg.GetStr("settings", "client_id");
|
std::string clientID = cfg.GetStr("settings", "client_id");
|
||||||
if (clientID.empty()) {
|
if (clientID.empty()) {
|
||||||
// V2 ID 算法:MachineGuid + 归一化路径
|
// V2 ID 算法:MachineGuid + 主机名 + 归一化路径
|
||||||
// - 同机同程序路径永远同 ID(不依赖 IP/PCName/OS/CPU 漂移)
|
// - 主机名兜底区分同镜像克隆机:MachineGuid 相同、路径相同仍能靠主机名区分。
|
||||||
// - 局域网多机即便同镜像(sysprep 会让 MachineGuid 各不同)也不撞库
|
// - 代价:改名会让 client_id 变化,丢该主机的备注/授权/历史;主机名非硬件唯一,重名仍撞。
|
||||||
// MachineGuid 读取失败的极端情况退化到老算法,保兼容。
|
// MachineGuid 读取失败的极端情况退化到老算法,保兼容。
|
||||||
std::string machineGuid = GetMachineGuidWindows();
|
std::string machineGuid = GetMachineGuidWindows();
|
||||||
if (!machineGuid.empty()) {
|
if (!machineGuid.empty()) {
|
||||||
conn.clientID = CalcalateIDv2(machineGuid, NormalizeExePathLower(buf), isAuth);
|
conn.clientID = CalcalateIDv2(machineGuid + "|" + szPCName, NormalizeExePathLower(buf), isAuth);
|
||||||
} else {
|
} else {
|
||||||
Mprintf("WARN: MachineGuid 读取失败,回退到老 ID 算法\n");
|
Mprintf("WARN: MachineGuid 读取失败,回退到老 ID 算法\n");
|
||||||
conn.clientID = CalcalateID({ pubIP, szPCName, LoginInfor.OsVerInfoEx, cpuInfo, buf });
|
conn.clientID = CalcalateID({ pubIP, szPCName, LoginInfor.OsVerInfoEx, cpuInfo, buf });
|
||||||
@@ -444,6 +473,7 @@ LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string
|
|||||||
double KB = fileInfo.nFileSizeLow > 1024 ? fileInfo.nFileSizeLow / 1024.0 : 0;
|
double KB = fileInfo.nFileSizeLow > 1024 ? fileInfo.nFileSizeLow / 1024.0 : 0;
|
||||||
sprintf_s(fileSize, "%.1f%s", MB > 0 ? MB : KB, MB > 0 ? "M" : "K");
|
sprintf_s(fileSize, "%.1f%s", MB > 0 ? MB : KB, MB > 0 ? "M" : "K");
|
||||||
LoginInfor.AddReserved(fileSize); // 文件大小
|
LoginInfor.AddReserved(fileSize); // 文件大小
|
||||||
|
LoginInfor.AddReserved(TlsEnabled() ? 1 : 0); // TLS 启用标志:有信任锚(证书)即 1
|
||||||
return LoginInfor;
|
return LoginInfor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
std::string GetMachineGuidWindows();
|
std::string GetMachineGuidWindows();
|
||||||
uint64_t CalcalateIDv2(const std::string& machineGuid, const std::string& normalizedPath, bool isAuth = false);
|
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);
|
BOOL IsAuthKernel(std::string& str);
|
||||||
LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS &conn, const std::string& expiredDate, bool isAuth);
|
LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS &conn, const std::string& expiredDate, bool isAuth);
|
||||||
DWORD CPUClockMHz();
|
DWORD CPUClockMHz();
|
||||||
|
|||||||
+17
-1
@@ -14,6 +14,7 @@
|
|||||||
#include <functional>
|
#include <functional>
|
||||||
#include <future>
|
#include <future>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <atomic>
|
||||||
#include <emmintrin.h> // SSE2
|
#include <emmintrin.h> // SSE2
|
||||||
#include "common/config.h"
|
#include "common/config.h"
|
||||||
#include "VideoEncoderBase.h"
|
#include "VideoEncoderBase.h"
|
||||||
@@ -149,6 +150,9 @@ public:
|
|||||||
int m_FrameID; // 帧序号
|
int m_FrameID; // 帧序号
|
||||||
int m_GOP; // 关键帧间隔
|
int m_GOP; // 关键帧间隔
|
||||||
bool m_SendKeyFrame; // 发送关键帧
|
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 探测
|
std::unique_ptr<VideoEncoderBase> m_encoder; // 编码器,ensureEncoder() lazy 创建,走 EncoderFactory 探测
|
||||||
bool m_bEncoderPrimed = false; // encoder 是否已成功产出过一个包;
|
bool m_bEncoderPrimed = false; // encoder 是否已成功产出过一个包;
|
||||||
// false 时禁止 skip——避免单显示器路径
|
// false 时禁止 skip——避免单显示器路径
|
||||||
@@ -257,6 +261,11 @@ public:
|
|||||||
m_BlockBuffers[blockY] = new BYTE[m_ulFullWidth * m_ulFullHeight * 4 * 2 / m_BlockNum + 12];
|
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()
|
virtual ~ScreenCapture()
|
||||||
{
|
{
|
||||||
if (m_BitmapInfor_Full != NULL) {
|
if (m_BitmapInfor_Full != NULL) {
|
||||||
@@ -876,7 +885,12 @@ public:
|
|||||||
{
|
{
|
||||||
BYTE algo = m_bAlgorithm;
|
BYTE algo = m_bAlgorithm;
|
||||||
int frameID = m_FrameID + 1;
|
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;
|
m_RectBuffer[0] = keyFrame ? TOKEN_KEYFRAME : TOKEN_NEXTSCREEN;
|
||||||
LPBYTE data = m_RectBuffer + 1;
|
LPBYTE data = m_RectBuffer + 1;
|
||||||
// 写入使用了哪种算法
|
// 写入使用了哪种算法
|
||||||
@@ -967,6 +981,8 @@ public:
|
|||||||
*ulNextSendLength = 1 + offset + encoded_size;
|
*ulNextSendLength = 1 + offset + encoded_size;
|
||||||
memcpy(data + offset, encoded_data, encoded_size);
|
memcpy(data + offset, encoded_data, encoded_size);
|
||||||
m_bEncoderPrimed = true; // 与下方 FirstBuffer 同步:自此 skip 安全
|
m_bEncoderPrimed = true; // 与下方 FirstBuffer 同步:自此 skip 安全
|
||||||
|
// IDR 已成功产出,满足(可能的)强制关键帧请求。
|
||||||
|
m_bForceKeyFrame.store(false, std::memory_order_relaxed);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -930,6 +930,13 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
|||||||
SwitchScreen();
|
SwitchScreen();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case COMMAND_SCREEN_KEYFRAME_REQ: {
|
||||||
|
// Web 屏幕墙恢复/新加入者请求:下一帧强制 IDR,立即重建参考链。
|
||||||
|
if (m_ScreenSpyObject) {
|
||||||
|
m_ScreenSpyObject->RequestKeyFrame();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case CMD_FULL_SCREEN: {
|
case CMD_FULL_SCREEN: {
|
||||||
int fullScreen = szBuffer[1];
|
int fullScreen = szBuffer[1];
|
||||||
iniFile cfg(CLIENT_PATH);
|
iniFile cfg(CLIENT_PATH);
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
// TlsChannel.cpp — 见 TlsChannel.h 头注释。仅 _WIN32 编译。
|
||||||
|
#ifdef _WIN32
|
||||||
|
|
||||||
|
#include "stdafx.h"
|
||||||
|
#include "TlsChannel.h"
|
||||||
|
|
||||||
|
#include "common/iniFile.h" // config(settings.ini 文件回退) + GetRegistryName/GetCurrentUserRegistryKey(注册表正文)
|
||||||
|
|
||||||
|
#include <wincrypt.h> // 证书/链引擎 API
|
||||||
|
#include <winreg.h> // RegOpenKeyExA / RegQueryValueExA
|
||||||
|
#include <io.h> // _access
|
||||||
|
#include <fstream> // std::ifstream
|
||||||
|
#include <iterator> // std::istreambuf_iterator
|
||||||
|
#include <string>
|
||||||
|
#include <mutex> // std::call_once
|
||||||
|
|
||||||
|
#pragma comment(lib, "secur32.lib")
|
||||||
|
#pragma comment(lib, "crypt32.lib")
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::once_flag g_tlsOnce;
|
||||||
|
bool g_tlsEnabled = false; // TLS 会话可用:send/recv 走加密
|
||||||
|
bool g_tlsError = false; // 证书存在但加载失败:拒绝连接(fail-closed)
|
||||||
|
HCERTSTORE g_tlsStore = NULL;
|
||||||
|
HCERTCHAINENGINE g_tlsEngine = NULL; // 只信任 g_tlsStore 的链引擎(严格钉扎)
|
||||||
|
|
||||||
|
// 解析信任锚证书路径:优先 settings.ini 的 [settings] tls_cert,其次可执行文件同目录
|
||||||
|
// yama-ca.pem。
|
||||||
|
std::string ResolveCertPath()
|
||||||
|
{
|
||||||
|
std::string certPath;
|
||||||
|
{
|
||||||
|
config cfg;
|
||||||
|
certPath = cfg.GetStr("settings", "tls_cert", "");
|
||||||
|
}
|
||||||
|
if (certPath.empty()) {
|
||||||
|
char path[MAX_PATH];
|
||||||
|
if (!GetModuleFileNameA(NULL, path, sizeof(path)))
|
||||||
|
return std::string(); // 拿不到 exe 路径,视为未配置
|
||||||
|
char* p = path + strlen(path);
|
||||||
|
while (p > path && *p != '\\') --p;
|
||||||
|
strcpy(p + 1, "yama-ca.pem");
|
||||||
|
certPath = path;
|
||||||
|
}
|
||||||
|
return certPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取注册表 REG_SZ(动态扩容)。iniFile::GetStr 用 512 字节固定缓冲,会把 PEM 正文
|
||||||
|
// (约 1.3~1.8KB)截断成空,故此处自行两段式 RegQueryValueEx 读取。
|
||||||
|
// 返回 true 表示值存在且为 REG_SZ,out 为去掉末尾 '\0' 后的内容;否则 false。
|
||||||
|
bool ReadRegistrySz(HKEY root, const std::string& subKey, const std::string& valueName, std::string& out)
|
||||||
|
{
|
||||||
|
HKEY hKey = NULL;
|
||||||
|
if (RegOpenKeyExA(root, subKey.c_str(), 0, KEY_READ, &hKey) != ERROR_SUCCESS)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
DWORD type = 0, size = 0;
|
||||||
|
LONG r = RegQueryValueExA(hKey, valueName.c_str(), NULL, &type, NULL, &size);
|
||||||
|
if (r != ERROR_SUCCESS || type != REG_SZ) {
|
||||||
|
RegCloseKey(hKey);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::vector<char> buf(size ? size : 1, 0);
|
||||||
|
r = RegQueryValueExA(hKey, valueName.c_str(), NULL, &type,
|
||||||
|
reinterpret_cast<LPBYTE>(buf.data()), &size);
|
||||||
|
RegCloseKey(hKey);
|
||||||
|
if (r != ERROR_SUCCESS)
|
||||||
|
return false;
|
||||||
|
if (size > 0 && buf[size - 1] == '\0')
|
||||||
|
--size; // REG_SZ 的 size 含末尾 '\0'
|
||||||
|
out.assign(buf.data(), size);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取信任锚 PEM 正文。优先注册表 Software\<exehash>\settings\tls_cert(REG_SZ 直接存
|
||||||
|
// PEM 正文,便于后续服务端下发/撤销),其次 settings.ini [settings] tls_cert 路径、
|
||||||
|
// exe 同目录 yama-ca.pem 文件(向后兼容旧的文件投递方式)。
|
||||||
|
// 返回 true 表示已配置(pem 可能为空:值/文件存在但内容为空,由 LoadTrustAnchor 判为
|
||||||
|
// fail-closed);false 表示完全未配置(走明文)。source 描述来源,仅用于日志。
|
||||||
|
bool ReadTrustAnchor(std::string& pem, std::string& source)
|
||||||
|
{
|
||||||
|
// 1) 注册表正文(首选)。值存在(即便为空)即视为「已配置」,交由 LoadTrustAnchor
|
||||||
|
// 对空/损坏内容判 fail-closed,与「空文件 → fail-closed」语义保持一致,避免空值
|
||||||
|
// 被静默当成未配置而回退明文。
|
||||||
|
std::string regSubKey = GetRegistryName() + "\\settings";
|
||||||
|
std::string regVal;
|
||||||
|
if (ReadRegistrySz(GetCurrentUserRegistryKey(), regSubKey, "tls_cert", regVal)) {
|
||||||
|
pem = regVal;
|
||||||
|
source = "registry " + regSubKey + "\\tls_cert";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 文件回退:settings.ini [settings] tls_cert 路径 → exe 同目录 yama-ca.pem
|
||||||
|
std::string certPath = ResolveCertPath();
|
||||||
|
if (certPath.empty() || _access(certPath.c_str(), 0) != 0)
|
||||||
|
return false; // 无证书 → 明文
|
||||||
|
|
||||||
|
std::ifstream f(certPath.c_str(), std::ios::binary);
|
||||||
|
if (!f) {
|
||||||
|
Mprintf("[TLS] cannot open %s\n", certPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pem.assign((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
||||||
|
source = certPath;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解码 PEM/DER 信任锚正文为 DER,并加入内存信任锚存储。
|
||||||
|
bool LoadTrustAnchor(const std::string& pem, const std::string& source, HCERTSTORE* outStore)
|
||||||
|
{
|
||||||
|
if (pem.empty()) {
|
||||||
|
Mprintf("[TLS] empty certificate %s\n", source.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PEM(-----BEGIN CERTIFICATE----- base64)或 DER → DER。CRYPT_STRING_ANY 两者都认,
|
||||||
|
// 失败再退回 CRYPT_STRING_BASE64HEADER(纯 PEM)。
|
||||||
|
DWORD derLen = 0;
|
||||||
|
if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_ANY,
|
||||||
|
NULL, &derLen, NULL, NULL) || derLen == 0) {
|
||||||
|
if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_BASE64HEADER,
|
||||||
|
NULL, &derLen, NULL, NULL) || derLen == 0) {
|
||||||
|
Mprintf("[TLS] failed to decode certificate %s\n", source.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::vector<BYTE> der(derLen);
|
||||||
|
if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_ANY,
|
||||||
|
der.data(), &derLen, NULL, NULL)) {
|
||||||
|
if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_BASE64HEADER,
|
||||||
|
der.data(), &derLen, NULL, NULL)) {
|
||||||
|
Mprintf("[TLS] failed to decode certificate %s\n", source.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PCCERT_CONTEXT cert = CertCreateCertificateContext(X509_ASN_ENCODING, der.data(), derLen);
|
||||||
|
if (!cert) {
|
||||||
|
Mprintf("[TLS] invalid certificate %s\n", source.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
HCERTSTORE store = CertOpenStore(CERT_STORE_PROV_MEMORY, X509_ASN_ENCODING,
|
||||||
|
0, CERT_STORE_CREATE_NEW_FLAG, NULL);
|
||||||
|
if (!store) {
|
||||||
|
CertFreeCertificateContext(cert);
|
||||||
|
Mprintf("[TLS] CertOpenStore failed\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!CertAddCertificateContextToStore(store, cert, CERT_STORE_ADD_REPLACE_EXISTING, NULL)) {
|
||||||
|
CertCloseStore(store, 0);
|
||||||
|
CertFreeCertificateContext(cert);
|
||||||
|
Mprintf("[TLS] CertAddCertificateContextToStore failed\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
CertFreeCertificateContext(cert);
|
||||||
|
|
||||||
|
*outStore = store;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void InitTls()
|
||||||
|
{
|
||||||
|
std::string pem, source;
|
||||||
|
if (!ReadTrustAnchor(pem, source)) {
|
||||||
|
// 无证书 → 明文(默认,与老客户端一致)
|
||||||
|
g_tlsEnabled = false;
|
||||||
|
g_tlsError = false;
|
||||||
|
Mprintf("[TLS] disabled: no trust anchor configured, plaintext mode\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 证书已配置:管理员已「颁发」,必须走 TLS;加载失败即拒绝连接(fail-closed),
|
||||||
|
// 不能静默回退明文。
|
||||||
|
HCERTSTORE store = NULL;
|
||||||
|
if (!LoadTrustAnchor(pem, source, &store)) {
|
||||||
|
g_tlsEnabled = false;
|
||||||
|
g_tlsError = true;
|
||||||
|
Mprintf("[TLS] FATAL: trust anchor (%s) present but failed to load; refusing connections (fail-closed)\n",
|
||||||
|
source.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 严格钉扎:链引擎只信任该 store 里的根证书,排除系统根库。否则攻击者拿一张任意
|
||||||
|
// 公共 CA 签发的证书就能通过 CertGetCertificateChain(默认引擎信任系统根库)。
|
||||||
|
CERT_CHAIN_ENGINE_CONFIG ec = {};
|
||||||
|
ec.cbSize = sizeof(ec);
|
||||||
|
ec.hExclusiveRoot = store;
|
||||||
|
HCERTCHAINENGINE engine = NULL;
|
||||||
|
if (!CertCreateCertificateChainEngine(&ec, &engine)) {
|
||||||
|
CertCloseStore(store, 0);
|
||||||
|
g_tlsEnabled = false;
|
||||||
|
g_tlsError = true;
|
||||||
|
Mprintf("[TLS] FATAL: CertCreateCertificateChainEngine failed; refusing connections (fail-closed)\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_tlsStore = store;
|
||||||
|
g_tlsEngine = engine;
|
||||||
|
g_tlsEnabled = true;
|
||||||
|
g_tlsError = false;
|
||||||
|
Mprintf("[TLS] enabled: trust anchor loaded (%s), all connections use TLS\n", source.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool TlsEnabled()
|
||||||
|
{
|
||||||
|
std::call_once(g_tlsOnce, InitTls);
|
||||||
|
return g_tlsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TlsLoadError()
|
||||||
|
{
|
||||||
|
std::call_once(g_tlsOnce, InitTls);
|
||||||
|
return g_tlsError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- TlsChannel ----
|
||||||
|
|
||||||
|
void TlsChannel::CloseHandles()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lk(m_ctxMtx);
|
||||||
|
if (m_ctxValid) { DeleteSecurityContext(&m_ctx); m_ctx = {}; m_ctxValid = false; }
|
||||||
|
if (m_credValid) { FreeCredentialsHandle(&m_cred); m_cred = {}; m_credValid = false; }
|
||||||
|
m_ready = false;
|
||||||
|
m_raw.clear();
|
||||||
|
m_pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TlsChannel::SendAll(SOCKET sock, const void* data, int len)
|
||||||
|
{
|
||||||
|
const char* p = (const char*)data;
|
||||||
|
int sent = 0;
|
||||||
|
while (sent < len) {
|
||||||
|
int n = ::send(sock, p + sent, len - sent, 0);
|
||||||
|
if (n == SOCKET_ERROR || n == 0)
|
||||||
|
return false;
|
||||||
|
sent += n;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TlsChannel::VerifyServerCert()
|
||||||
|
{
|
||||||
|
PCCERT_CONTEXT remote = NULL;
|
||||||
|
SECURITY_STATUS sc = QueryContextAttributesA(&m_ctx, SECPKG_ATTR_REMOTE_CERT_CONTEXT, &remote);
|
||||||
|
if (sc != SEC_E_OK || !remote)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!g_tlsStore || !g_tlsEngine) {
|
||||||
|
CertFreeCertificateContext(remote);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CERT_CHAIN_PARA para = {};
|
||||||
|
para.cbSize = sizeof(para);
|
||||||
|
PCCERT_CHAIN_CONTEXT chain = NULL;
|
||||||
|
// g_tlsEngine 的 hExclusiveRoot 已把信任锚限制为 g_tlsStore;hAdditionalStore 再传一次
|
||||||
|
// 该 store 供链引擎查找 CA/中间证书。只接受「无任何错误位」的链(含时间有效性)。
|
||||||
|
// 不设置任何 CERT_CHAIN_REVOCATION_CHECK_* 位 = 不做吊销检查(私有 CA 无 CRL/OCSP,
|
||||||
|
// 且这些位会触发联网,反而失败);只保留 CERT_CHAIN_DISABLE_AIA 禁止联网取中间证书。
|
||||||
|
BOOL ok = CertGetCertificateChain(g_tlsEngine, remote, NULL, g_tlsStore, ¶,
|
||||||
|
CERT_CHAIN_DISABLE_AIA,
|
||||||
|
NULL, &chain);
|
||||||
|
bool trusted = false;
|
||||||
|
if (ok && chain) {
|
||||||
|
trusted = (chain->TrustStatus.dwErrorStatus == CERT_TRUST_NO_ERROR);
|
||||||
|
}
|
||||||
|
if (chain) CertFreeCertificateChain(chain);
|
||||||
|
CertFreeCertificateContext(remote);
|
||||||
|
return trusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TlsChannel::Handshake(SOCKET sock)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lk(m_ctxMtx);
|
||||||
|
CloseHandles(); // 重连时清理上一次会话(recursive 锁,此处可重入)
|
||||||
|
|
||||||
|
SCHANNEL_CRED cred = {};
|
||||||
|
cred.dwVersion = SCHANNEL_CRED_VERSION;
|
||||||
|
cred.dwFlags = SCH_CRED_MANUAL_CRED_VALIDATION | SCH_CRED_NO_DEFAULT_CREDS;
|
||||||
|
cred.grbitEnabledProtocols = SP_PROT_TLS1_2_CLIENT; // TLS 1.2(与 Go 端 MinVersion 对齐)
|
||||||
|
|
||||||
|
TimeStamp tsExpiry = {};
|
||||||
|
SECURITY_STATUS sc = AcquireCredentialsHandleA(
|
||||||
|
NULL, (SEC_CHAR*)"Microsoft Unified Security Protocol Provider",
|
||||||
|
SECPKG_CRED_OUTBOUND, NULL, &cred, NULL, NULL, &m_cred, &tsExpiry);
|
||||||
|
if (sc != SEC_E_OK) {
|
||||||
|
Mprintf("[TLS] AcquireCredentialsHandle failed: 0x%08X\n", (unsigned)sc);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
m_credValid = true;
|
||||||
|
|
||||||
|
DWORD flags = ISC_REQ_SEQUENCE_DETECT | ISC_REQ_REPLAY_DETECT | ISC_REQ_CONFIDENTIALITY
|
||||||
|
| ISC_REQ_ALLOCATE_MEMORY | ISC_REQ_STREAM | ISC_REQ_MANUAL_CRED_VALIDATION;
|
||||||
|
|
||||||
|
SecBuffer outBuf = { 0, SECBUFFER_TOKEN, NULL };
|
||||||
|
SecBufferDesc outDesc = { SECBUFFER_VERSION, 1, &outBuf };
|
||||||
|
ULONG attrs = 0;
|
||||||
|
|
||||||
|
// 首次调用产生 ClientHello
|
||||||
|
sc = InitializeSecurityContextA(&m_cred, NULL, NULL, flags, 0, SECURITY_NATIVE_DREP,
|
||||||
|
NULL, 0, &m_ctx, &outDesc, &attrs, &tsExpiry);
|
||||||
|
m_ctxValid = true; // 上下文已分配,之后必须 DeleteSecurityContext
|
||||||
|
if (sc != SEC_I_CONTINUE_NEEDED && sc != SEC_E_OK) {
|
||||||
|
Mprintf("[TLS] InitializeSecurityContext(initial) failed: 0x%08X\n", (unsigned)sc);
|
||||||
|
CloseHandles();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (outBuf.cbBuffer > 0 && outBuf.pvBuffer) {
|
||||||
|
if (!SendAll(sock, outBuf.pvBuffer, (int)outBuf.cbBuffer)) {
|
||||||
|
FreeContextBuffer(outBuf.pvBuffer);
|
||||||
|
CloseHandles();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
FreeContextBuffer(outBuf.pvBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<BYTE> hs;
|
||||||
|
char raw[8192];
|
||||||
|
while (sc == SEC_I_CONTINUE_NEEDED || sc == SEC_E_INCOMPLETE_MESSAGE ||
|
||||||
|
sc == SEC_I_INCOMPLETE_CREDENTIALS)
|
||||||
|
{
|
||||||
|
int r = recv(sock, raw, sizeof(raw), 0);
|
||||||
|
if (r == SOCKET_ERROR || r == 0) {
|
||||||
|
Mprintf("[TLS] handshake recv failed/EOF\n");
|
||||||
|
CloseHandles();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
hs.insert(hs.end(), (BYTE*)raw, (BYTE*)raw + r);
|
||||||
|
|
||||||
|
SecBuffer inBufs[2] = {
|
||||||
|
{ (ULONG)hs.size(), SECBUFFER_TOKEN, hs.data() },
|
||||||
|
{ 0, SECBUFFER_EMPTY, NULL },
|
||||||
|
};
|
||||||
|
SecBufferDesc inDesc = { SECBUFFER_VERSION, 2, inBufs };
|
||||||
|
|
||||||
|
outBuf = { 0, SECBUFFER_TOKEN, NULL };
|
||||||
|
outDesc = { SECBUFFER_VERSION, 1, &outBuf };
|
||||||
|
|
||||||
|
sc = InitializeSecurityContextA(&m_cred, &m_ctx, NULL, flags, 0, SECURITY_NATIVE_DREP,
|
||||||
|
&inDesc, 0, NULL, &outDesc, &attrs, &tsExpiry);
|
||||||
|
|
||||||
|
// 裁剪已消费的输入,仅保留 SECBUFFER_EXTRA 指向的未消费尾部
|
||||||
|
if (inBufs[1].BufferType == SECBUFFER_EXTRA) {
|
||||||
|
int extra = inBufs[1].cbBuffer;
|
||||||
|
int consumed = (int)hs.size() - extra;
|
||||||
|
hs.erase(hs.begin(), hs.begin() + consumed);
|
||||||
|
} else {
|
||||||
|
hs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outBuf.cbBuffer > 0 && outBuf.pvBuffer) {
|
||||||
|
if (!SendAll(sock, outBuf.pvBuffer, (int)outBuf.cbBuffer)) {
|
||||||
|
FreeContextBuffer(outBuf.pvBuffer);
|
||||||
|
CloseHandles();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
FreeContextBuffer(outBuf.pvBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sc != SEC_E_OK) {
|
||||||
|
Mprintf("[TLS] handshake failed: 0x%08X\n", (unsigned)sc);
|
||||||
|
CloseHandles();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 握手成功后 hs 里可能残留「未消费的尾部」= 服务端 Finished 后紧跟的首条应用数据
|
||||||
|
// (TLS 记录)。若不下发,首包会被丢弃,导致随机丢包/协议错位。交给 Recv 解。
|
||||||
|
if (!hs.empty())
|
||||||
|
m_raw.insert(m_raw.end(), hs.begin(), hs.end());
|
||||||
|
|
||||||
|
if (!VerifyServerCert()) {
|
||||||
|
Mprintf("[TLS] server certificate not trusted against yama-ca.pem (fail-closed)\n");
|
||||||
|
CloseHandles();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
sc = QueryContextAttributesA(&m_ctx, SECPKG_ATTR_STREAM_SIZES, &m_sizes);
|
||||||
|
if (sc != SEC_E_OK) {
|
||||||
|
Mprintf("[TLS] QueryContextAttributes(StreamSizes) failed: 0x%08X\n", (unsigned)sc);
|
||||||
|
CloseHandles();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_ready = true;
|
||||||
|
Mprintf("[TLS] handshake OK (TLS 1.2, server cert trusted)\n");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int TlsChannel::Recv(SOCKET sock, char* out, int outLen)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lk(m_ctxMtx);
|
||||||
|
if (!m_ready || outLen <= 0) {
|
||||||
|
Mprintf("[TLS] Recv called but m_ready=%d (outLen=%d), returning SOCKET_ERROR\n",
|
||||||
|
(int)m_ready, outLen);
|
||||||
|
return SOCKET_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_pending.empty()) {
|
||||||
|
int n = (int)m_pending.size() < outLen ? (int)m_pending.size() : outLen;
|
||||||
|
memcpy(out, m_pending.data(), n);
|
||||||
|
m_pending.erase(m_pending.begin(), m_pending.begin() + n);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
char raw[16384];
|
||||||
|
for (;;) {
|
||||||
|
// 优先解密 m_raw 里已缓冲的字节(握手时随 Finished 收下的首条应用数据,或上轮
|
||||||
|
// 一次 recv 到多条记录后剩下的记录)。select() 只看底层 socket,看不到这些字节,
|
||||||
|
// 若先 recv 就会把它们卡住(随机延迟/死锁/协议错位)。
|
||||||
|
while (!m_raw.empty()) {
|
||||||
|
SecBuffer bufs[4] = {
|
||||||
|
{ (ULONG)m_raw.size(), SECBUFFER_DATA, m_raw.data() },
|
||||||
|
{ 0, SECBUFFER_EMPTY, NULL },
|
||||||
|
{ 0, SECBUFFER_EMPTY, NULL },
|
||||||
|
{ 0, SECBUFFER_EMPTY, NULL },
|
||||||
|
};
|
||||||
|
SecBufferDesc desc = { SECBUFFER_VERSION, 4, bufs };
|
||||||
|
SECURITY_STATUS sc = DecryptMessage(&m_ctx, &desc, 0, NULL);
|
||||||
|
|
||||||
|
if (sc == SEC_E_INCOMPLETE_MESSAGE)
|
||||||
|
break; // 记录不完整,去 recv 补字节
|
||||||
|
if (sc == SEC_I_CONTEXT_EXPIRED)
|
||||||
|
return 0; // close_notify
|
||||||
|
if (sc == SEC_I_RENEGOTIATE || FAILED(sc)) {
|
||||||
|
// 客户端解密失败(服务端发来的密文损坏/错位)。这里只返回 SOCKET_ERROR,
|
||||||
|
// 而 WSAGetLastError() 会残留上一次 Winsock 调用的旧值(如 6001),
|
||||||
|
// 掩盖真实原因,故把真实的 SECURITY_STATUS 记下来。
|
||||||
|
Mprintf("[TLS] client DecryptMessage failed 0x%08x (raw=%u pending=%u)\n",
|
||||||
|
(unsigned)sc, (unsigned)m_raw.size(), (unsigned)m_pending.size());
|
||||||
|
return SOCKET_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流模式 DecryptMessage 会把输入 SECBUFFER_DATA 重组为 4 段:
|
||||||
|
// bufs[0]=STREAM_HEADER(记录头+nonce)、bufs[1]=DATA(明文)、
|
||||||
|
// bufs[2]=STREAM_TRAILER(标签)、bufs[3]=EXTRA(下一条记录)。
|
||||||
|
// 明文在 bufs[1].pvBuffer,不在 m_raw 首字节。
|
||||||
|
int decLen = bufs[1].cbBuffer;
|
||||||
|
const BYTE* plain = (const BYTE*)bufs[1].pvBuffer;
|
||||||
|
int extra = (bufs[3].BufferType == SECBUFFER_EXTRA) ? bufs[3].cbBuffer : 0;
|
||||||
|
int consumed = (int)m_raw.size() - extra;
|
||||||
|
m_pending.insert(m_pending.end(), plain, plain + decLen);
|
||||||
|
m_raw.erase(m_raw.begin(), m_raw.begin() + consumed);
|
||||||
|
|
||||||
|
if (!m_pending.empty()) {
|
||||||
|
int n = (int)m_pending.size() < outLen ? (int)m_pending.size() : outLen;
|
||||||
|
memcpy(out, m_pending.data(), n);
|
||||||
|
m_pending.erase(m_pending.begin(), m_pending.begin() + n);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
// 无明文的记录(如 alert/CCS):继续循环解下一条
|
||||||
|
}
|
||||||
|
|
||||||
|
int r = recv(sock, raw, sizeof(raw), 0);
|
||||||
|
if (r == SOCKET_ERROR)
|
||||||
|
return SOCKET_ERROR;
|
||||||
|
if (r == 0)
|
||||||
|
return 0; // 对端关闭(此时无 pending,且 m_raw 已解完/为空)
|
||||||
|
m_raw.insert(m_raw.end(), (BYTE*)raw, (BYTE*)raw + r);
|
||||||
|
// 循环回顶部,解密刚收到的字节
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int TlsChannel::Send(SOCKET sock, const char* data, int len)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lk(m_ctxMtx);
|
||||||
|
if (!m_ready)
|
||||||
|
return SOCKET_ERROR;
|
||||||
|
if (len <= 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
ULONG maxMsg = m_sizes.cbMaximumMessage > 0 ? m_sizes.cbMaximumMessage : 16384;
|
||||||
|
int total = 0;
|
||||||
|
while (total < len) {
|
||||||
|
int chunk = (len - total > (int)maxMsg) ? (int)maxMsg : (len - total);
|
||||||
|
|
||||||
|
std::vector<BYTE> rec(m_sizes.cbHeader + chunk + m_sizes.cbTrailer);
|
||||||
|
BYTE* hdr = rec.data();
|
||||||
|
BYTE* body = hdr + m_sizes.cbHeader;
|
||||||
|
BYTE* trailer = body + chunk;
|
||||||
|
memcpy(body, data + total, chunk);
|
||||||
|
|
||||||
|
SecBuffer bufs[4] = {
|
||||||
|
{ m_sizes.cbHeader, SECBUFFER_STREAM_HEADER, hdr },
|
||||||
|
{ (ULONG)chunk, SECBUFFER_DATA, body },
|
||||||
|
{ m_sizes.cbTrailer, SECBUFFER_STREAM_TRAILER, trailer },
|
||||||
|
{ 0, SECBUFFER_EMPTY, NULL },
|
||||||
|
};
|
||||||
|
SecBufferDesc desc = { SECBUFFER_VERSION, 4, bufs };
|
||||||
|
SECURITY_STATUS sc = EncryptMessage(&m_ctx, 0, &desc, 0);
|
||||||
|
if (FAILED(sc)) {
|
||||||
|
return total > 0 ? total : SOCKET_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
int wireLen = bufs[0].cbBuffer + bufs[1].cbBuffer + bufs[2].cbBuffer;
|
||||||
|
if (!SendAll(sock, rec.data(), wireLen)) {
|
||||||
|
return total > 0 ? total : SOCKET_ERROR;
|
||||||
|
}
|
||||||
|
total += chunk;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // _WIN32
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// TlsChannel.h — Windows 客户端单向 TLS(Schannel)会话封装
|
||||||
|
//
|
||||||
|
// 用途:为 YAMA 客户端(被控端)提供「按需开启」的传输层加密。当信任锚证书存在时
|
||||||
|
// (注册表 Software\<exehash>\settings\tls_cert,或 yama-ca.pem 文件,见 TlsChannel.cpp
|
||||||
|
// 的 InitTls),所有出站 TCP 连接在应用协议首包前先完成 TLS 握手;握手失败一律断开
|
||||||
|
// (fail-closed,不回退明文)。
|
||||||
|
//
|
||||||
|
// 选型:Schannel(Windows 原生 SSPI),零第三方依赖;secur32.lib / crypt32.lib 为
|
||||||
|
// 系统库。单向 TLS:客户端只校验服务端证书链到固定的信任锚,不提供客户端证书,
|
||||||
|
// 客户端身份仍由隧道内登录(V1 PwdHmac / V2 ECDSA)承担。
|
||||||
|
//
|
||||||
|
// 线程模型:Handshake 在连接建立线程上、m_bConnected 置位前调用;Send/Recv 分别在
|
||||||
|
// 发送线程 / 接收线程上调用。上下文生命周期(Handshake/CloseHandles 释放 m_ctx)与
|
||||||
|
// Send/Recv 对 m_ctx 的使用(EncryptMessage/DecryptMessage)由 m_ctxMtx 互斥,避免
|
||||||
|
// 重连线程释放上下文时与使用线程产生 use-after-free。本对象作为 IOCPClient 的值成员
|
||||||
|
// 存在,析构时释放上下文。
|
||||||
|
//
|
||||||
|
// 注意:本文件仅在 _WIN32 下有效,非 Windows 构建(Linux/macOS/Android)不含任何内容,
|
||||||
|
// 客户端维持明文(TLS 接入按设计文档延后)。
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
|
||||||
|
#include <WinSock2.h>
|
||||||
|
|
||||||
|
#ifndef SECURITY_WIN32
|
||||||
|
#define SECURITY_WIN32 1
|
||||||
|
#endif
|
||||||
|
#include <security.h>
|
||||||
|
#include <schannel.h>
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// 进程级 TLS 开关:首次调用时加载信任锚(注册表或文件),返回是否启用。线程安全、幂等。
|
||||||
|
bool TlsEnabled();
|
||||||
|
|
||||||
|
// 信任锚已配置但加载失败(损坏/格式错误)时为 true:表示管理员已「颁发」证书,
|
||||||
|
// 客户端必须拒绝连接(fail-closed),不得静默回退明文。线程安全、幂等。
|
||||||
|
bool TlsLoadError();
|
||||||
|
|
||||||
|
class TlsChannel
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
TlsChannel() {}
|
||||||
|
~TlsChannel() { CloseHandles(); }
|
||||||
|
|
||||||
|
// 持 Schannel 句柄,禁止拷贝/赋值(避免句柄被二次释放)。
|
||||||
|
TlsChannel(const TlsChannel&) = delete;
|
||||||
|
TlsChannel& operator=(const TlsChannel&) = delete;
|
||||||
|
|
||||||
|
// 在已连通的 socket 上完成 TLS 客户端握手并校验服务端证书链。失败返回 false,
|
||||||
|
// 调用方必须关闭 socket(fail-closed)。可重复调用(重连时复用同一对象)。
|
||||||
|
bool Handshake(SOCKET sock);
|
||||||
|
|
||||||
|
// 解密读取:返回 >0 的明文字节数;0 表示对端正常关闭;SOCKET_ERROR(-1) 出错。
|
||||||
|
int Recv(SOCKET sock, char* buf, int len);
|
||||||
|
|
||||||
|
// 加密发送 len 字节:成功返回已完整发送的明文字节数(== len);失败返回已发送的
|
||||||
|
// 前缀字节数(>0)或 SOCKET_ERROR(一个字节都没发出去时)。
|
||||||
|
int Send(SOCKET sock, const char* buf, int len);
|
||||||
|
|
||||||
|
// 是否有待交付的数据:已解密明文(m_pending)或已缓冲但尚未解密的 TLS 记录(m_raw)。
|
||||||
|
// 供接收线程在 select 前优先消费——select 只看底层 socket,看不到 Schannel 缓冲的
|
||||||
|
// m_raw(握手时随 Finished 收下的首条应用数据、或一次 recv 到多条记录后的剩余),
|
||||||
|
// 若这里不把 m_raw 也算作「有数据」,这些字节会被 select 卡住。
|
||||||
|
bool HasPending() const { return !m_pending.empty() || !m_raw.empty(); }
|
||||||
|
|
||||||
|
// 握手是否已完成(Send/Recv 可用)。诊断用:区分「本连接已走 TLS」与「裸连接」。
|
||||||
|
bool IsReady() const { return m_ready; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void CloseHandles();
|
||||||
|
bool SendAll(SOCKET sock, const void* data, int len);
|
||||||
|
bool VerifyServerCert();
|
||||||
|
|
||||||
|
CredHandle m_cred = {};
|
||||||
|
CtxtHandle m_ctx = {};
|
||||||
|
bool m_credValid = false; // m_cred 已分配,需 FreeCredentialsHandle
|
||||||
|
bool m_ctxValid = false; // m_ctx 已分配,需 DeleteSecurityContext
|
||||||
|
bool m_ready = false; // 握手完成,Send/Recv 可用
|
||||||
|
|
||||||
|
SecPkgContext_StreamSizes m_sizes = {};
|
||||||
|
|
||||||
|
// 保护上下文生命周期:重连线程的 Handshake/CloseHandles(释放 m_ctx)与工作线程的
|
||||||
|
// Recv(DecryptMessage)、发送线程的 Send(EncryptMessage)互斥,避免 use-after-free。
|
||||||
|
// recursive 是因为 Handshake 内部会再调 CloseHandles。
|
||||||
|
std::recursive_mutex m_ctxMtx;
|
||||||
|
|
||||||
|
std::vector<BYTE> m_raw; // 尚未解密 / 未消费完的 TLS 记录原始字节
|
||||||
|
std::vector<BYTE> m_pending; // 已解密、未交付给应用层的明文
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // _WIN32
|
||||||
@@ -233,6 +233,7 @@
|
|||||||
<ClCompile Include="CFFmpegH264Encoder.cpp" />
|
<ClCompile Include="CFFmpegH264Encoder.cpp" />
|
||||||
<ClCompile Include="EncoderFactory.cpp" />
|
<ClCompile Include="EncoderFactory.cpp" />
|
||||||
<ClCompile Include="TalkManager.cpp" />
|
<ClCompile Include="TalkManager.cpp" />
|
||||||
|
<ClCompile Include="TlsChannel.cpp" />
|
||||||
<ClCompile Include="ChatManager.cpp" />
|
<ClCompile Include="ChatManager.cpp" />
|
||||||
<ClCompile Include="MachineManager.cpp" />
|
<ClCompile Include="MachineManager.cpp" />
|
||||||
<ClCompile Include="VideoManager.cpp" />
|
<ClCompile Include="VideoManager.cpp" />
|
||||||
@@ -299,6 +300,7 @@
|
|||||||
<ClInclude Include="SystemManager.h" />
|
<ClInclude Include="SystemManager.h" />
|
||||||
<ClInclude Include="VideoEncoderBase.h" />
|
<ClInclude Include="VideoEncoderBase.h" />
|
||||||
<ClInclude Include="TalkManager.h" />
|
<ClInclude Include="TalkManager.h" />
|
||||||
|
<ClInclude Include="TlsChannel.h" />
|
||||||
<ClInclude Include="ChatManager.h" />
|
<ClInclude Include="ChatManager.h" />
|
||||||
<ClInclude Include="MachineManager.h" />
|
<ClInclude Include="MachineManager.h" />
|
||||||
<ClInclude Include="yapi.hpp" />
|
<ClInclude Include="yapi.hpp" />
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<ItemGroup>
|
||||||
|
<ClCompile Include="..\common\file_upload.cpp" />
|
||||||
|
<ClCompile Include="..\common\ikcp.c" />
|
||||||
|
<ClCompile Include="..\common\zstd_wrapper.c" />
|
||||||
|
<ClCompile Include="..\server\2015Remote\pwd_gen.cpp" />
|
||||||
|
<ClCompile Include="ActivityHistory.cpp" />
|
||||||
|
<ClCompile Include="Audio.cpp" />
|
||||||
|
<ClCompile Include="AudioManager.cpp" />
|
||||||
|
<ClCompile Include="Buffer.cpp" />
|
||||||
|
<ClCompile Include="CaptureVideo.cpp" />
|
||||||
|
<ClCompile Include="clang_rt_compat.c" />
|
||||||
|
<ClCompile Include="ClientDll.cpp" />
|
||||||
|
<ClCompile Include="Common.cpp" />
|
||||||
|
<ClCompile Include="FileManager.cpp" />
|
||||||
|
<ClCompile Include="file\FileManager.cpp" />
|
||||||
|
<ClCompile Include="file\MasterEncoder.cpp" />
|
||||||
|
<ClCompile Include="file\zip.cpp" />
|
||||||
|
<ClCompile Include="screenhide\ScreenHideManager.cpp" />
|
||||||
|
<ClCompile Include="DrawingBoardMgr.cpp" />
|
||||||
|
<ClCompile Include="IOCPClient.cpp" />
|
||||||
|
<ClCompile Include="IOCPKCPClient.cpp" />
|
||||||
|
<ClCompile Include="IOCPUDPClient.cpp" />
|
||||||
|
<ClCompile Include="KernelManager.cpp" />
|
||||||
|
<ClCompile Include="KeyboardManager.cpp" />
|
||||||
|
<ClCompile Include="keylogger.cpp" />
|
||||||
|
<ClCompile Include="Loader.cpp" />
|
||||||
|
<ClCompile Include="LoginServer.cpp" />
|
||||||
|
<ClCompile Include="ClientLogManager.cpp" />
|
||||||
|
<ClCompile Include="Manager.cpp" />
|
||||||
|
<ClCompile Include="MemoryModule.c" />
|
||||||
|
<ClCompile Include="proxy\ProxyManager.cpp" />
|
||||||
|
<ClCompile Include="RegisterManager.cpp" />
|
||||||
|
<ClCompile Include="RegisterOperation.cpp" />
|
||||||
|
<ClCompile Include="reg_startup.c" />
|
||||||
|
<ClCompile Include="SafeThread.cpp" />
|
||||||
|
<ClCompile Include="ScreenManager.cpp" />
|
||||||
|
<ClCompile Include="ScreenPreview.cpp" />
|
||||||
|
<ClCompile Include="ScreenSpy.cpp" />
|
||||||
|
<ClCompile Include="ServicesManager.cpp" />
|
||||||
|
<ClCompile Include="ConnectionsManager.cpp" />
|
||||||
|
<ClCompile Include="ServiceWrapper.c" />
|
||||||
|
<ClCompile Include="session.cpp" />
|
||||||
|
<ClCompile Include="SessionMonitor.c" />
|
||||||
|
<ClCompile Include="ShellManager.cpp" />
|
||||||
|
<ClCompile Include="ConPTYManager.cpp" />
|
||||||
|
<ClCompile Include="StdAfx.cpp" />
|
||||||
|
<ClCompile Include="SystemManager.cpp" />
|
||||||
|
<ClCompile Include="CFFmpegAV1Encoder.cpp" />
|
||||||
|
<ClCompile Include="CFFmpegH264Encoder.cpp" />
|
||||||
|
<ClCompile Include="EncoderFactory.cpp" />
|
||||||
|
<ClCompile Include="TalkManager.cpp" />
|
||||||
|
<ClCompile Include="TlsChannel.cpp" />
|
||||||
|
<ClCompile Include="ChatManager.cpp" />
|
||||||
|
<ClCompile Include="MachineManager.cpp" />
|
||||||
|
<ClCompile Include="VideoManager.cpp" />
|
||||||
|
<ClCompile Include="X264Encoder.cpp" />
|
||||||
|
<ClCompile Include="decrypt\DecryptManager.cpp">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClCompile>
|
||||||
|
<ClCompile Include="decrypt\Get360seInfo.cpp">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClCompile>
|
||||||
|
<ClCompile Include="decrypt\GetBrowserInfo.cpp">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClCompile>
|
||||||
|
<ClCompile Include="decrypt\UnCode.cpp">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClCompile>
|
||||||
|
<ClCompile Include="decrypt\sqlite3\rijndael.c">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClCompile>
|
||||||
|
<ClCompile Include="decrypt\sqlite3\sqlite3.c">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClCompile>
|
||||||
|
<ClCompile Include="decrypt\cJSON\cJSON.c">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClCompile>
|
||||||
|
<ClCompile Include="decrypt\cJSON\cJSON_Utils.c">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClCompile>
|
||||||
|
<ClCompile Include="decrypt\base64\base64.c">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClCompile>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ClInclude Include="..\common\file_upload.h" />
|
||||||
|
<ClInclude Include="..\common\ikcp.h" />
|
||||||
|
<ClInclude Include="..\common\mask.h" />
|
||||||
|
<ClInclude Include="..\common\wallet.h" />
|
||||||
|
<ClInclude Include="..\common\zstd_wrapper.h" />
|
||||||
|
<ClInclude Include="..\server\2015Remote\pwd_gen.h" />
|
||||||
|
<ClInclude Include="ActivityHistory.h" />
|
||||||
|
<ClInclude Include="Audio.h" />
|
||||||
|
<ClInclude Include="AudioManager.h" />
|
||||||
|
<ClInclude Include="auto_start.h" />
|
||||||
|
<ClInclude Include="Buffer.h" />
|
||||||
|
<ClInclude Include="CaptureVideo.h" />
|
||||||
|
<ClInclude Include="ClientApp.h" />
|
||||||
|
<ClInclude Include="clip.h" />
|
||||||
|
<ClInclude Include="Common.h" />
|
||||||
|
<ClInclude Include="CursorInfo.h" />
|
||||||
|
<ClInclude Include="domain_pool.h" />
|
||||||
|
<ClInclude Include="FileManager.h" />
|
||||||
|
<ClInclude Include="file\FileManager.h" />
|
||||||
|
<ClInclude Include="file\MasterEncoder.h" />
|
||||||
|
<ClInclude Include="file\zip.h" />
|
||||||
|
<ClInclude Include="screenhide\ScreenManagerBase.h" />
|
||||||
|
<ClInclude Include="screenhide\ScreenHideManager.h" />
|
||||||
|
<ClInclude Include="screenhide\ScreenHideSpy.h" />
|
||||||
|
<ClInclude Include="DrawingBoardMgr.h" />
|
||||||
|
<ClInclude Include="IOCPClient.h" />
|
||||||
|
<ClInclude Include="IOCPKCPClient.h" />
|
||||||
|
<ClInclude Include="IOCPUDPClient.h" />
|
||||||
|
<ClInclude Include="KernelManager.h" />
|
||||||
|
<ClInclude Include="KeyboardManager.h" />
|
||||||
|
<ClInclude Include="keylogger.h" />
|
||||||
|
<ClInclude Include="LoginServer.h" />
|
||||||
|
<ClInclude Include="Manager.h" />
|
||||||
|
<ClInclude Include="ClientLogManager.h" />
|
||||||
|
<ClInclude Include="MemoryModule.h" />
|
||||||
|
<ClInclude Include="my_clip.h" />
|
||||||
|
<ClInclude Include="proxy\ProxyManager.h" />
|
||||||
|
<ClInclude Include="RegisterManager.h" />
|
||||||
|
<ClInclude Include="RegisterOperation.h" />
|
||||||
|
<ClInclude Include="reg_startup.h" />
|
||||||
|
<ClInclude Include="resource.h" />
|
||||||
|
<ClInclude Include="SafeThread.h" />
|
||||||
|
<ClInclude Include="ScreenCapturerDXGI.h" />
|
||||||
|
<ClInclude Include="ScreenManager.h" />
|
||||||
|
<ClInclude Include="ScreenPreview.h" />
|
||||||
|
<ClInclude Include="ScreenSpy.h" />
|
||||||
|
<ClInclude Include="ServicesManager.h" />
|
||||||
|
<ClInclude Include="ConnectionsManager.h" />
|
||||||
|
<ClInclude Include="ServiceWrapper.h" />
|
||||||
|
<ClInclude Include="session.h" />
|
||||||
|
<ClInclude Include="SessionMonitor.h" />
|
||||||
|
<ClInclude Include="ShellManager.h" />
|
||||||
|
<ClInclude Include="ConPTYManager.h" />
|
||||||
|
<ClInclude Include="StdAfx.h" />
|
||||||
|
<ClInclude Include="CFFmpegAV1Encoder.h" />
|
||||||
|
<ClInclude Include="CFFmpegH264Encoder.h" />
|
||||||
|
<ClInclude Include="EncoderFactory.h" />
|
||||||
|
<ClInclude Include="SystemManager.h" />
|
||||||
|
<ClInclude Include="VideoEncoderBase.h" />
|
||||||
|
<ClInclude Include="TalkManager.h" />
|
||||||
|
<ClInclude Include="TlsChannel.h" />
|
||||||
|
<ClInclude Include="ChatManager.h" />
|
||||||
|
<ClInclude Include="MachineManager.h" />
|
||||||
|
<ClInclude Include="yapi.hpp" />
|
||||||
|
<ClInclude Include="VideoCodec.h" />
|
||||||
|
<ClInclude Include="VideoManager.h" />
|
||||||
|
<ClInclude Include="X264Encoder.h" />
|
||||||
|
<ClInclude Include="decrypt\DecryptManager.h">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClInclude>
|
||||||
|
<ClInclude Include="decrypt\Get360seInfo.h">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClInclude>
|
||||||
|
<ClInclude Include="decrypt\GetBrowserInfo.h">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClInclude>
|
||||||
|
<ClInclude Include="decrypt\UnCode.h">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClInclude>
|
||||||
|
<ClInclude Include="decrypt\sqlite3\rijndael.h">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClInclude>
|
||||||
|
<ClInclude Include="decrypt\sqlite3\sqlite3.h">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClInclude>
|
||||||
|
<ClInclude Include="decrypt\sqlite3\sqlite3ext.h">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClInclude>
|
||||||
|
<ClInclude Include="decrypt\cJSON\cJSON.h">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClInclude>
|
||||||
|
<ClInclude Include="decrypt\cJSON\cJSON_Utils.h">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClInclude>
|
||||||
|
<ClInclude Include="decrypt\base64\base64.h">
|
||||||
|
<Filter>decrypt</Filter>
|
||||||
|
</ClInclude>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ResourceCompile Include="Script.rc" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Media Include="Res\msg.wav" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Image Include="Res\ghost.ico" />
|
||||||
|
<Image Include="Res\msg.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="ExportFunTable.def" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Filter Include="decrypt">
|
||||||
|
<UniqueIdentifier>{4b839e4d-0a78-4ee2-8965-bfb259b28273}</UniqueIdentifier>
|
||||||
|
</Filter>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
+6
-2
@@ -495,8 +495,12 @@ BOOL Run(const char* argv1, int argv2, const std::string &runCmd)
|
|||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
run(ip, port);
|
run(ip, port);
|
||||||
while (bStop && !bStop() && 0 == status)
|
if (bExit && bExit() != S_SERVER_EXIT) {
|
||||||
Sleep(20);
|
// 受控端退出 / 程序更新:等所有实例(含分享实例)退干净,避免下方 FreeLibrary
|
||||||
|
// 卸载 DLL 时残留分享实例线程。主控端重启(S_SERVER_EXIT)则立即重连,不等仍存活的分享实例。
|
||||||
|
while (bStop && !bStop() && 0 == status)
|
||||||
|
Sleep(20);
|
||||||
|
}
|
||||||
} while (bExit && !bExit() && 0 == status);
|
} while (bExit && !bExit() && 0 == status);
|
||||||
|
|
||||||
while (bStop && !bStop() && 1 == status)
|
while (bStop && !bStop() && 1 == status)
|
||||||
|
|||||||
@@ -325,6 +325,10 @@ enum {
|
|||||||
COMMAND_FILE_MANAGER_PLUGIN = 163, // 服务端 → 客户端:打开插件文件管理对话框(SimplePlugins 迁移)
|
COMMAND_FILE_MANAGER_PLUGIN = 163, // 服务端 → 客户端:打开插件文件管理对话框(SimplePlugins 迁移)
|
||||||
COMMAND_PRIVATE_SCREEN_PLUGIN = 164, // 服务端 → 客户端:打开隐私屏幕(虚拟桌面,SimplePlugins 迁移)
|
COMMAND_PRIVATE_SCREEN_PLUGIN = 164, // 服务端 → 客户端:打开隐私屏幕(虚拟桌面,SimplePlugins 迁移)
|
||||||
COMMAND_DRAWING_BOARD_PLUGIN = 165, // 服务端 → 客户端:打开远程画板(SimplePlugins 迁移)
|
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_DECRYPT = 199,
|
||||||
TOKEN_REGEDIT = 200, // 注册表
|
TOKEN_REGEDIT = 200, // 注册表
|
||||||
@@ -1061,6 +1065,7 @@ enum LOGIN_RES {
|
|||||||
RES_CLIENT_ID = 16, // 客户端唯一ID
|
RES_CLIENT_ID = 16, // 客户端唯一ID
|
||||||
RES_PID = 17, // 进程ID
|
RES_PID = 17, // 进程ID
|
||||||
RES_FILESIZE = 18, // 文件大小
|
RES_FILESIZE = 18, // 文件大小
|
||||||
|
RES_TLS = 19, // TLS 传输启用标志 0/1
|
||||||
RES_MAX,
|
RES_MAX,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# 传输层单向 TLS 加密设计(按需加密)
|
||||||
|
|
||||||
|
> 版本:v0.5 · 状态:**Go 服务端 + Windows 客户端已实现并验证;信任锚改存注册表;MFC 主控端(阶段 3)未施工** · 日期:2026-10-14
|
||||||
|
> 目标:让 YAMA 设备端口(TCP 6543)在继续兼容老客户端(明文/XOR)的同时,允许新客户端在拿到证书后按需开启**单向 TLS** 加密传输。
|
||||||
|
> 本期范围:**Go 服务端(`server/go`)+ Windows 客户端**;MFC 服务端(`server/2015Remote`)延后。
|
||||||
|
>
|
||||||
|
> 需求原文(三点,逐一对应下文):
|
||||||
|
> 1. 新客户端**默认不加密**,行为与老客户端完全一致;
|
||||||
|
> 2. 某些高要求场景,管理员「颁发一个证书给该客户端 + 重启」,该客户端即改走加密;
|
||||||
|
> 3. 服务端同一端口(6543)同时兼容明文与 TLS 两种客户端,老客户端零改动。
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
- 同一 `6543` 端口上,服务端自动区分「明文客户端」与「TLS 客户端」,按连接路由。
|
||||||
|
- 加密是**按部署、按客户端开关**的:证书(注册表值或文件)在 → TLS;证书不在 → 明文(与老客户端一致)。
|
||||||
|
- 老客户端(无证书概念)行为不变、零改动。
|
||||||
|
- 满足 Measure 1 的「传输层真加密 + 抗重放」目标(对 TLS 客户端而言)。
|
||||||
|
|
||||||
|
### 非目标(Non-goals,本次不做)
|
||||||
|
|
||||||
|
- 不做 **mTLS / 客户端证书**:单向 TLS 不验证客户端证书,客户端身份仍由隧道内的现有登录(`PwdHmac` V1 / `ECDSA` V2)承担。
|
||||||
|
- 不做传输层 PSK(`YAMA_TRANSPORT_KEY`)方案——本设计改用证书,更贴合「颁发证书」的运维模型。
|
||||||
|
- 不改应用层协议(flag 帧格式 / XOR / zstd),不做「TLS 内去掉 XOR」的二次精简(后续可选)。
|
||||||
|
- 不做加密开关的运行时热切换(证书只在客户端启动时读取一次,改配置需重启——这正是需求)。
|
||||||
|
- 不做 Linux / macOS / Android 客户端的 TLS 接入(本文以 Windows MFC 客户端为主,其余平台同样式跟进)。
|
||||||
|
- 不做 **MFC 服务端(`server/2015Remote`)的 TLS 接入**——本期只做 Go 服务端(`server/go`),MFC 主控端延后到阶段 3。
|
||||||
|
|
||||||
|
## 2. 现状与复用
|
||||||
|
|
||||||
|
### 2.1 当前传输(已核实)
|
||||||
|
|
||||||
|
服务端 TCP 传输链路:`server/go/server/server.go` `acceptLoop()`(329–360 行)`Accept` 后 `go handleConnection(conn)`;`handleConnection()`(363–425 行)读入 `ctx.InBuffer`,交给 `protocol.Parser`。
|
||||||
|
|
||||||
|
`server/go/protocol/parser.go` `Parse()`(51–85 行):
|
||||||
|
|
||||||
|
1. 可选剥离 HTTP-mask(`POST /...\r\n\r\n` 外壳,`common/mask.h` 的 `HttpMask`);
|
||||||
|
2. `parseHeader()`(88–140 行)调用 `CheckHead()` 识别协议;
|
||||||
|
3. `parsePacket()`(143–225 行)解头 + XOR 解码 + zstd 解压。
|
||||||
|
|
||||||
|
`server/go/protocol/header.go` `CheckHead()`(149–165 行)对前 12 字节依次尝试 8 种头混淆 × 4 种魔数,命中即得 `FlagType`:
|
||||||
|
|
||||||
|
| FlagType | 魔数 | 首字节 |
|
||||||
|
|---|---|---|
|
||||||
|
| FlagHell(4) | `HELL` | `H`=0x48 |
|
||||||
|
| FlagShine(1) | `Shine` | `S`=0x53 |
|
||||||
|
| FlagFuck(2) | `<<FUCK>>` | `<`=0x3C |
|
||||||
|
| FlagHello(3) | `Hello?` | `H`=0x48 |
|
||||||
|
| HTTP-mask | `POST ` | `P`=0x50 |
|
||||||
|
|
||||||
|
> 注意:上表「首字节」是**解密后**的魔数首字节。实际线上,客户端(`common/header.h` 的 `GetHead`)会用 `time() % 12` 随机选一个混淆方法对前 4 字节魔数加密(仅在 `superAdmin` 命中的 `HellEncoder` 路径;默认 `ProtocolEncoder` 直接发明文 `Shine`)。因此**原始首字节可能是任意值**,包括 `0x16`。这正是 TLS 检测不能只看前两字节、必须再加一字节判据的原因(见 §3.1)。
|
||||||
|
|
||||||
|
当前「加密」为 `codec.go` 的 `xorDecoder16`(XOR + 两轮伪随机交换,密钥是明文带内 1~2 字节)+ `header.go` 的 V0~V6 头混淆——**等于明文**。这正是 Measure 1 要解决的。
|
||||||
|
|
||||||
|
### 2.2 客户端登录(已核实,隧道内照常)
|
||||||
|
|
||||||
|
- V1:`client/KernelManager.cpp:1857` 从配置读 `settings/PwdHmac`(预计算摘要),服务端 `auth.go:240` `VerifyMessage(YAMA_PWD, passcode, hmacSig)` 校验。`YAMA_PWD` 只存在服务端,客户端只有摘要,不持有 `YAMA_PWD`。
|
||||||
|
- V2:`auth.go:188` ECDSA P-256 验签(`v2:BASE64`),客户端持私钥、服务端持公钥。
|
||||||
|
|
||||||
|
### 2.3 关键结论(本设计的基础)
|
||||||
|
|
||||||
|
**TLS 作为「透明传输包装」,应用层协议一行不改。** 即:
|
||||||
|
|
||||||
|
```
|
||||||
|
明文客户端: [TCP] = flag 帧 + XOR payload + zstd
|
||||||
|
TLS 客户端: [TCP][TLS] = { flag 帧 + XOR payload + zstd }
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端 `parser.go`/`codec.go`/`header.go`/handler 全部不动;TLS 客户端解密后的字节流就是同一套协议。唯一新增的是**连接建立时的 TLS 检测与解包**。XOR 套在 TLS 里冗余但无害,保留它换来「应用协议零改动、老新客户端共用同一套代码」。
|
||||||
|
|
||||||
|
## 3. 总体方案
|
||||||
|
|
||||||
|
### 3.1 同端口共存 + 首字节检测
|
||||||
|
|
||||||
|
服务端在 `handleConnection` 开头(`Accept` 之后)偷看连接开头 6 字节:
|
||||||
|
|
||||||
|
- 命中 `0x16 0x03 … 0x01`(TLS 握手记录 + TLS 版本号 + 握手类型 ClientHello)→ 交给 `tls.Server` 解包;
|
||||||
|
- 其它 → 走现有明文路径。
|
||||||
|
|
||||||
|
**为何要看第 6 字节**:客户端会随机混淆魔数头(见 §2.1),所以明文连接的前两字节**可能**恰好是 `0x16 0x03`。但混淆只作用于前 4 字节魔数,字节 5 对混淆后的 `HELL` 头恒为 `0x00`,对明文 `Shine`/`POST` 变体是 ASCII 字符——**永远不会是 `0x01`**;而任何 TLS 握手记录里,字节 5 就是握手消息类型,ClientHello 恒为 `0x01`。于是 `0x16 0x03` 加 `b[5]==0x01` 三重判据让误判概率趋近于零。
|
||||||
|
|
||||||
|
```
|
||||||
|
accept
|
||||||
|
├─ 现有 MaxConnections / per-IP 限流(保持,先于 TLS 握手,廉价拒绝 + 抗握手 DoS)
|
||||||
|
├─ peek 3 字节
|
||||||
|
│ ├─ 0x16 0x03 → tls.Server(conn, tlsCfg)(惰性握手,首次 Read 触发)
|
||||||
|
│ └─ 其它 → 明文
|
||||||
|
└─ handleConnection(conn) // 不区分明文/TLS,tls.Conn 实现 net.Conn
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 客户端行为
|
||||||
|
|
||||||
|
客户端启动时检查「证书文件是否存在」:
|
||||||
|
|
||||||
|
- 不在 → 明文(与老客户端完全一致,默认即此);
|
||||||
|
- 在 → 所有出站 TCP 连接(主连接 + screen/terminal/file 子连接)统一走 TLS。
|
||||||
|
|
||||||
|
### 3.3 架构图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────── 被控端(客户端,无证书) ────────────┐
|
||||||
|
│ TCP ── flag+XOR+zstd ─────────────────────────► │ 明文
|
||||||
|
└───────────────────────────────────────────────────┘
|
||||||
|
┌──────────── 被控端(客户端,有证书) ────────────┐
|
||||||
|
│ TCP ── TLS ── { flag+XOR+zstd } ──────────────► │ 加密
|
||||||
|
└───────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ 服务端 6543 │
|
||||||
|
│ peek 首字节路由 │
|
||||||
|
│ ├─ 0x16 0x03 → TLS │
|
||||||
|
│ └─ 其它 → 明文 │
|
||||||
|
│ ── 同一套 parser/handler │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 证书模型(单向 TLS)
|
||||||
|
|
||||||
|
- 服务端持有一张**服务端证书 + 私钥**(可自签名,或由自建 CA 签发)。
|
||||||
|
- 客户端持有**信任锚证书**(CA 证书,或自签名服务端证书本身)用于在握手时校验服务端。
|
||||||
|
- 「颁发证书给某客户端」= 把信任锚证书投递到该客户端(**注册表值**,见 §6.1/§9.3)。证书是**共享的信任锚**(不是每个客户端一张唯一证书),「颁发」的本质是**决定给哪个客户端开加密**。
|
||||||
|
- 单向 TLS **不认证客户端身份**;客户端身份仍由隧道内的登录(V1 `PwdHmac` / V2 ECDSA)承担。
|
||||||
|
- 信任锚首选存注册表 `Software\<exehash>\settings\tls_cert`(REG_SZ 直接存 PEM 正文);兼容旧方式:`settings.ini` `[settings] tls_cert` 路径,或 exe 同目录 `yama-ca.pem` 文件。
|
||||||
|
|
||||||
|
## 5. 服务端改动
|
||||||
|
|
||||||
|
### 5.1 配置(`server/go/server/server.go` `Config`)
|
||||||
|
|
||||||
|
新增两个字段,均为证书/私钥文件路径;空 = 禁用 TLS(仅明文,向后兼容):
|
||||||
|
|
||||||
|
```go
|
||||||
|
TLSCertFile string // 服务端证书(PEM)
|
||||||
|
TLSKeyFile string // 服务端私钥(PEM)
|
||||||
|
```
|
||||||
|
|
||||||
|
`cmd/main.go` 读取环境变量(沿用现有 `YAMA_*` 命名):
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg.TLSCertFile = os.Getenv("YAMA_TLS_CERT")
|
||||||
|
cfg.TLSKeyFile = os.Getenv("YAMA_TLS_KEY")
|
||||||
|
```
|
||||||
|
|
||||||
|
仅当两者都非空才构建 `*tls.Config`;否则 `s.tlsCfg == nil`,`acceptLoop` 走纯明文。
|
||||||
|
|
||||||
|
### 5.2 `tls.Config`
|
||||||
|
|
||||||
|
```go
|
||||||
|
tlsCfg := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
// 单向 TLS:不设 ClientAuth / ClientCAs
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 `handleConnection` 检测 + 回放
|
||||||
|
|
||||||
|
标准 peek 回放:`bufio.Reader` 预读 6 字节,封装成 `bufferedConn`(实现 `net.Conn`,`Read` 委托给 `Reader`),判断后把已读字节一并交给目标。嗅探本身设了 `ReadTimeout` 截止,超时/EOF 则退回原始 conn 走明文,由读循环处理:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Server) wrapTLS(conn net.Conn) net.Conn {
|
||||||
|
br := bufio.NewReader(conn)
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(s.config.ReadTimeout)) // 嗅探受超时约束
|
||||||
|
hdr, err := br.Peek(6)
|
||||||
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
return conn // 超时/EOF:走明文,交由读循环处理
|
||||||
|
}
|
||||||
|
bc := &bufferedConn{Conn: conn, r: br}
|
||||||
|
if isTLSClientHello(hdr) { // 0x16 0x03 且 b[5]==0x01
|
||||||
|
return tls.Server(bc, s.tlsCfg) // 握手惰性,首次 Read 触发
|
||||||
|
}
|
||||||
|
return bc
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 握手超时
|
||||||
|
|
||||||
|
`handleConnection` 首次 `conn.Read` 会触发 TLS 握手,而它已按 `ReadTimeout` 设了 `SetReadDeadline`,因此握手天然受 `ReadTimeout` 约束,无需额外机制。per-IP 限流(现有)继续在握手前生效,压制握手 DoS。
|
||||||
|
|
||||||
|
### 5.5 明确不动的部分
|
||||||
|
|
||||||
|
- `parser.go` / `codec.go` / `header.go`:**零改动**。
|
||||||
|
- `handleConnection` / `processData` / handler 栈:**零改动**(`tls.Conn` 满足 `net.Conn`)。
|
||||||
|
|
||||||
|
## 6. 客户端改动
|
||||||
|
|
||||||
|
### 6.1 证书检测
|
||||||
|
|
||||||
|
启动时(连接前)按优先级找信任锚:① 注册表 `Software\<exehash>\settings\tls_cert`(REG_SZ 存 PEM 正文)→ ② `settings.ini` `[settings] tls_cert` 路径 → ③ exe 同目录 `yama-ca.pem`。找到即走 TLS,否则明文。实现:`client/TlsChannel.cpp` 的 `ReadTrustAnchor()` + `TlsEnabled()`(`std::call_once` 惰性加载、线程安全、幂等)。
|
||||||
|
|
||||||
|
### 6.2 TLS 栈选型(已定:Schannel)
|
||||||
|
|
||||||
|
- **已选 Schannel(Windows 原生 SSPI)**:零第三方依赖/零额外 DLL 部署,`secur32.lib`/`crypt32.lib` 均为系统库(`secur32.lib` 已在 `CFFmpegH264Encoder.cpp:38` 链接)。
|
||||||
|
- 信任锚(PEM/DER 正文,来自注册表或文件)经 `CryptStringToBinaryA` 解码 → `CertCreateCertificateContext` → 内存 `HCERTSTORE`;握手后 `CertGetCertificateChain` 把服务端证书链到该信任锚,`dwErrorStatus == 0` 才放行(含时间有效性)。**严格钉扎**:链引擎用 `hExclusiveRoot` 只信任该 store,排除系统根库,否则公共 CA 签发的证书也能通过。**fail-closed**。
|
||||||
|
- 握手凭据:`SCH_CRED_MANUAL_CRED_VALIDATION | SCH_CRED_NO_DEFAULT_CREDS`,`grbitEnabledProtocols = SP_PROT_TLS1_2_CLIENT`(与 Go 端 `MinVersion: TLS 1.2` 对齐)。
|
||||||
|
- 单向 TLS:不提供客户端证书;客户端身份仍由隧道内登录承担。
|
||||||
|
|
||||||
|
### 6.3 连接层封装
|
||||||
|
|
||||||
|
在 socket 建立后、应用协议首包前插入 TLS 握手(`IOCPClient::ConnectServer`,`m_bConnected` 置位前);`ReceiveData`/`SendTo` 改走 `TlsChannel` 会话。所有出站连接(主连接 + 子连接)统一遵循 TLS 开关,因此子连接同样被加密——服务端按连接检测,天然支持。实现:`client/TlsChannel.{h,cpp}`,接入点 `client/IOCPClient.{h,cpp}`。
|
||||||
|
|
||||||
|
### 6.4 关键:无静默降级(fail-closed)
|
||||||
|
|
||||||
|
**证书文件存在但握手失败(证书不匹配 / 中间人)时,客户端必须报错并放弃连接,禁止回退到明文。** 否则攻击者可制造降级。这必须写进实现与测试。
|
||||||
|
|
||||||
|
### 6.5 实现要点:Schannel 流模式 `DecryptMessage` 的缓冲重组
|
||||||
|
|
||||||
|
Schannel 流模式(`ISC_REQ_STREAM`)下,`DecryptMessage` 会把输入的那段 `SECBUFFER_DATA`
|
||||||
|
**重组**为 4 段输出,而不是就地解密到原偏移:
|
||||||
|
|
||||||
|
| 索引 | 类型 | 内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| `bufs[0]` | `SECBUFFER_STREAM_HEADER` | TLS 记录头(5) + 显式 nonce(8) |
|
||||||
|
| `bufs[1]` | `SECBUFFER_DATA` | **明文** |
|
||||||
|
| `bufs[2]` | `SECBUFFER_STREAM_TRAILER` | 认证标签 |
|
||||||
|
| `bufs[3]` | `SECBUFFER_EXTRA` | 下一条记录的剩余密文(如有) |
|
||||||
|
|
||||||
|
因此客户端 `Recv` 必须从 `bufs[1].pvBuffer`/`bufs[1].cbBuffer` 取明文、`EXTRA` 从 `bufs[3]`
|
||||||
|
取。若误用 `bufs[0]`(记录头+nonce)或从原缓冲首字节取数,会把 TLS 记录头 `17 03 03 …`
|
||||||
|
当作明文交给协议解析器(表现为 `Unknown header`)。`Send` 方向相反:`EncryptMessage` 用
|
||||||
|
`STREAM_HEADER/DATA/TRAILER` 三段布局,本就正确。这一处是联调阶段实际踩到的、唯一的方向性
|
||||||
|
不对称 bug,已修复。
|
||||||
|
|
||||||
|
## 7. 兼容矩阵
|
||||||
|
|
||||||
|
| 客户端 | 服务端行为 | 结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 老客户端(无证书概念) | 明文路径 | 与现状完全一致 |
|
||||||
|
| 新客户端 · 无证书 | 明文路径 | 与老客户端行为一致(默认) |
|
||||||
|
| 新客户端 · 有证书 | TLS 路径 | 全量加密(主 + 子连接) |
|
||||||
|
|
||||||
|
服务端 TLS 未配置(`YAMA_TLS_CERT/KEY` 为空)时,一切与现状一致,TLS 客户端握手自然失败(fail-closed)。
|
||||||
|
|
||||||
|
## 8. 安全考量
|
||||||
|
|
||||||
|
- **TLS 提供**:机密性、完整性、服务端身份(客户端校验)、抗重放(TLS record 序号 + nonce)。即对 TLS 客户端而言,Measure 1(真加密 + 防重放)达成。
|
||||||
|
- **TLS 不提供**(单向):客户端证书身份——仍由隧道内登录承担;对「未颁证客户端」不强制加密(这是本设计刻意保留的兼容性,也是薄弱点:部署安全性取决于最弱客户端)。
|
||||||
|
- **明文前导**:TLS ClientHello 前几字节明文(协议固有),仅暴露「这是 TLS 客户端」,不含业务数据;客户端不设 SNI(连接目标是 IP,无域名)。
|
||||||
|
- **子连接**:客户端统一走 TLS,screen/terminal/file 子连接一并加密;对应 Measure 2 的「源 IP 绑定」降级为纵深防御,不再是唯一防线。
|
||||||
|
- **证书轮换**:重签服务端证书后,需向已颁证客户端重新投递新信任锚(或在证书到期前提前过渡)。自签名建议 `-days 3650`,到期前统一更换。
|
||||||
|
- **协议强度**:`MinVersion` 固定 TLS 1.2+,套件用 Go 默认安全集(`tls.Config` 默认已排除弱套件)。
|
||||||
|
|
||||||
|
## 9. 部署与运维
|
||||||
|
|
||||||
|
### 9.1 生成证书
|
||||||
|
|
||||||
|
自签名(最简,客户端信任锚即 `yama-server.crt`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||||
|
-keyout yama-server.key -out yama-server.crt \
|
||||||
|
-days 3650 -subj "/CN=yama-server"
|
||||||
|
```
|
||||||
|
|
||||||
|
或自建 CA + 签服务端证书(便于轮换,客户端信任锚为 `ca.crt`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 3650 -subj "/CN=yama-ca"
|
||||||
|
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=yama-server"
|
||||||
|
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 3650
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 服务端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
YAMA_TLS_CERT=/etc/yama/server.crt \
|
||||||
|
YAMA_TLS_KEY=/etc/yama/server.key \
|
||||||
|
./server_linux_amd64 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 客户端「颁发 + 重启」
|
||||||
|
|
||||||
|
首选:把信任锚 PEM 正文写入注册表值 `Software\<exehash>\settings\tls_cert`(REG_SZ),重启客户端:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$pem = Get-Content -Raw 'C:\path\yama-ca.pem'
|
||||||
|
New-Item -Path 'HKCU:\Software\<exehash>\settings' -Force | Out-Null
|
||||||
|
Set-ItemProperty -Path 'HKCU:\Software\<exehash>\settings' -Name 'tls_cert' -Value $pem -Type String
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<exehash>` = 客户端 exe 全路径小写后的 FNV 哈希(`common/iniFile.h` 的 `GetRegistryName()`)。
|
||||||
|
- 客户端以服务(SYSTEM)运行时,注册表落在会话用户的 `HKEY_USERS\<SID>`(`GetCurrentUserRegistryKey()`)。
|
||||||
|
- REG_SZ 直接存 PEM 正文(含换行);客户端动态读取,不受 `iniFile::GetStr` 512 字节固定缓冲限制。
|
||||||
|
|
||||||
|
兼容旧方式:把信任锚复制到客户端目录并命名为 `yama-ca.pem`(或经 `settings.ini` `[settings] tls_cert` 指向),重启客户端。
|
||||||
|
|
||||||
|
移除注册表值(或删除文件)并重启即退回明文。**远程下发/撤销信任锚属后续工作,本期仅支持本地写注册表。**
|
||||||
|
|
||||||
|
## 10. 测试计划
|
||||||
|
|
||||||
|
1. **检测单元**:`isTLSClientHello` 对 `0x16 0x03 … 0x01`(TLS 1.2/1.3 ClientHello)/ `POST ` / `Shine` / 混淆 `HELL`(首两字节恰好 `0x16 0x03` 但 `b[5]==0x00`)/ 短于 6 字节 等判定的单测(已实现 `server/go/server/tls_test.go`)。
|
||||||
|
2. **TLS 未配置**:不设 `YAMA_TLS_*` → 纯明文,行为与现状一致。
|
||||||
|
3. **明文回归**:老客户端 + 新客户端(无证书)均走明文,登录/屏幕/终端/文件全链路不回归。
|
||||||
|
4. **TLS 加密**:新客户端(有证书)→ 握手成功,登录/屏幕/终端/文件子连接全部在 TLS 内跑通;抓包确认 payload 不可读。
|
||||||
|
5. **混合并发**:明文与 TLS 客户端同时在线,互不干扰。
|
||||||
|
6. **fail-closed**:客户端有证书但服务端证书不匹配 / 中间人 → 客户端报错断开,**不**回退明文。
|
||||||
|
7. **握手超时 / 限流**:恶意慢握手被 `ReadTimeout` + per-IP 限流压制。
|
||||||
|
|
||||||
|
## 11. 分期与回滚
|
||||||
|
|
||||||
|
- **阶段 1(Go 服务端,本期优先)**:`Config` + `tls.Config` + `acceptLoop` 检测回放;明文路径零改动。可独立上线、独立验证——用 `openssl s_client` 或 Go 测试客户端即可联调 TLS 链路,无需真实客户端配合。
|
||||||
|
- **阶段 2(Windows 客户端)**:证书检测 + TLS 栈 + fail-closed。
|
||||||
|
- **阶段 3(MFC 服务端 `server/2015Remote`,延后)**:与 Go 服务端同款的 TLS 检测 + 解包接入。设计稿见 `docs/TransportTLS_MFCServer_Design.md`。
|
||||||
|
- **回滚**:服务端清空 `YAMA_TLS_CERT/KEY` 即回到纯明文;客户端删除注册表 `tls_cert` 值(或 `yama-ca.pem` 文件)即回明文。
|
||||||
|
|
||||||
|
## 12. 待定问题
|
||||||
|
|
||||||
|
1. ~~客户端 TLS 栈最终选型~~ → **已定 Schannel**(见 §6.2,2026-10-13 落地)。
|
||||||
|
2. ~~信任锚文件固定名(`yama-ca.pem`)是否需可配置~~ → **已改为注册表 `tls_cert` 值优先、文件回退**(见 §6.1/§9.3,2026-10-14)。
|
||||||
|
3. 是否同步把「TLS 内去掉 XOR」列为后续精简项(当前保留 XOR,换取零协议改动)。
|
||||||
|
4. Linux / macOS / Android 客户端的 TLS 接入排期。
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# MFC 服务端(server/2015Remote)单向 TLS 加密设计
|
||||||
|
|
||||||
|
> 版本:v0.3 · 状态:**已实施并验证(屏幕 + 终端联调通过)** · 日期:2026-10-15
|
||||||
|
> 上游依赖:`docs/TransportTLS_Design.md`(Go 服务端 + Windows 客户端,已实现并验证)。
|
||||||
|
> 本期范围:**MFC 服务端 `server/2015Remote` 的传输层加密**(加载证书+私钥、握手、加解密、新旧客户端并存)。
|
||||||
|
> 显式排除(本次不做,但须预埋接缝):① MFC 的 MCP 式配置 UI;② 「向在线主机下发是否启用加密传输」的指令;③ 客户端运行时热切换。
|
||||||
|
>
|
||||||
|
> **已拍板的三个决策(2026-10-14,见 §12)**:
|
||||||
|
> 1. 握手采用 **方案 A(专用短命阻塞线程)**;
|
||||||
|
> 2. 身份文件采用 **PEM + CNG(`CertPath` + `KeyPath`)**,与 Go 服务端共用同一对密钥;私钥须为 **PKCS#8**;
|
||||||
|
> 3. 配置 **新建 `[tls]` 节**(`Enabled` / `CertPath` / `KeyPath`)。
|
||||||
|
>
|
||||||
|
> 需求原文(已确认):
|
||||||
|
> 1. MFC 服务端有一个类似 MCP 的配置,可选启用传输层加密;启用时需指定一对密钥(证书 + 私钥);
|
||||||
|
> 2. 启用后,可向在线主机发送「是否启用加密传输」的指令 —— **UI 与新指令属后期内容**;
|
||||||
|
> 3. **新旧客户端都须支持**:同一端口同时兼容明文(老客户端)与 TLS(新客户端)。
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
- MFC 服务端在同一个 TCP 端口(6543)上,按连接自动区分「明文客户端」与「TLS 客户端」,路由到各自路径。
|
||||||
|
- TLS 客户端解密后的字节流 == 同一套应用协议(flag 帧 + XOR + zstd),**协议解析/解压/解码零改动**。
|
||||||
|
- 老客户端(无 TLS 概念)行为不变、零改动。
|
||||||
|
- 服务端「启用 TLS」是**配置驱动**的:未配证书 → 纯明文;配了证书 → 新旧并存。
|
||||||
|
|
||||||
|
### 非目标(Non-goals,本次不做)
|
||||||
|
|
||||||
|
- 不做 **mTLS / 客户端证书**:单向 TLS,客户端身份仍由隧道内登录(V1 `PwdHmac` / V2 `ECDSA`)承担。
|
||||||
|
- 不做「TLS 内去掉 XOR」的二次精简(与上游设计一致,保留 XOR 换取零协议改动)。
|
||||||
|
- 不做 **UI**:不新增配置对话框、不加「启用加密」复选框、不加证书/私钥路径选择控件。
|
||||||
|
- 不做 **「向在线主机下发启用/撤销加密」指令**:不下发信任锚、不发加密开关指令、不改客户端运行时状态。
|
||||||
|
- 不做 KCP/UDP 传输(`IOCPKCPServer`)的 TLS 接入(本期只做 TCP 设备端口;KCP 另行评估)。
|
||||||
|
|
||||||
|
## 2. 现状与复用(IOCP 数据流 + 四个接缝)
|
||||||
|
|
||||||
|
MFC 服务端 TCP 接收链路:`OnAccept`(`IOCPServer.cpp:1144`)`accept` 后 `AllocateContext`(`:1289`)分配 `CONTEXT_OBJECT`,绑定 IOCP,`PostRecv`(`:1268`)投递首个 `WSARecv`(`:1278`,收进 `wsaInBuf`→`szBuffer[PACKET_LENGTH]`,`:1215`)。收满后 `HandleIO(IORead)`(`:746`)→ `OnClientReceiving`(`:892`)→ `ParseReceivedData`(`:796`)。
|
||||||
|
|
||||||
|
原始 socket 字节在整套流程里各只有**一个入口**和**一个出口**,这是 TLS 能做成「过滤器」而非「改写」的根本原因:
|
||||||
|
|
||||||
|
| 接缝 | 位置 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| ① 收包咽喉点 | `IOCPServer.cpp:805` `InCompressedBuffer.WriteBuffer(szBuffer, dwTrans)` | 原始字节进入协议解析的**唯一**位置(`szBuffer` 全文件仅此一处消费) |
|
||||||
|
| ② 发包咽喉点 | `IOCPServer.cpp:1000` `WSASend(...wsaOutBuffer...)` | `OutCompressedBuffer` 上行的**唯一**位置 |
|
||||||
|
| ③ 嗅探点 | `OnAccept`→`PostRecv`;`OnClientInitializing`(`:790`)现为空桩 `return TRUE` | 首次收到数据处,可挂 ClientHello 判定 |
|
||||||
|
| ④ 状态字段 | `Server.h:332` `CONTEXT_OBJECT`(`szBuffer`/`wsaInBuf`/`InCompressedBuffer`/`InDeCompressedBuffer`/`OutCompressedBuffer`/`Parser` 等,`:359-398`) | 新增 TLS 会话指针 + 标志位 |
|
||||||
|
|
||||||
|
**关键结论(本设计的基础)**:TLS 作为「透明传输包装」,加解密插在 ①② 两处,③ 做新旧分流,④ 加一个 per-connection 状态。`ParseReceivedData` 内的 `Parse`/解压/`Decode`、`WriteContextData` 内的 `Encode`/压缩/掩码,**全部不动**。这与上游 Go 服务端「`tls.Conn` 满足 `net.Conn`、parser 零改动」是同一策略(见上游 §2.3)。
|
||||||
|
|
||||||
|
## 3. 总体方案
|
||||||
|
|
||||||
|
### 3.1 按连接嗅探(与 Go 服务端同款判定)
|
||||||
|
|
||||||
|
首次收到数据后,只看 `szBuffer` 前 6 字节:
|
||||||
|
|
||||||
|
- 命中 `0x16 0x03 … 0x01`(TLS 握手记录 + TLS 版本号 + ClientHello)→ TLS 路径;
|
||||||
|
- 其它 → 明文路径(老客户端透明,一行不改)。
|
||||||
|
|
||||||
|
判据与 Go 端 `isTLSClientHello`(`0x16 0x03` 且 `b[5]==0x01`)**完全一致**,理由也一致:客户端会随机混淆魔数头(见上游 §2.1),明文前两字节可能恰为 `0x16 0x03`,但字节 5 对混淆头恒非 `0x01`,故三重判据误判率趋近于零。
|
||||||
|
|
||||||
|
### 3.2 数据路径 = 条件过滤器
|
||||||
|
|
||||||
|
```
|
||||||
|
OnAccept → PostRecv
|
||||||
|
│
|
||||||
|
首次 IORead 完成(嗅探 szBuffer[0..5])
|
||||||
|
┌─────┴─────┐
|
||||||
|
明文(其它) TLS(0x16 0x03 .. 0x01)
|
||||||
|
│ │
|
||||||
|
ParseReceivedData TLS 握手(AcceptSecurityContext)
|
||||||
|
(现状,零改动) │ 成功
|
||||||
|
│ ▼
|
||||||
|
│ 握手后:IORead → DecryptMessage → 明文 → InCompressedBuffer.WriteBuffer
|
||||||
|
│ 发送:OutCompressedBuffer → EncryptMessage → WSASend
|
||||||
|
└────────── 同一套 Parse/Encode/解压/压缩 ──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
明文连接与握手后的 TLS 连接,下游共用同一套 `Parse`/`Encode`/解压/压缩。唯一差别是 ①② 两处各多一个 `if (tls) 加解密 else 直通` 分支。
|
||||||
|
|
||||||
|
## 4. 配置设计(含未来 UI/指令的预埋)
|
||||||
|
|
||||||
|
### 4.1 配置面(本次定键名,UI 后期加)
|
||||||
|
|
||||||
|
MFC 服务端配置走 `THIS_CFG`(`config`/`iniFile`,`GetInt`/`GetStr(section,key,default)`,见 `2015Remote.cpp:481-543` 用法)。新增 `[tls]` 节,三键(**已拍板 PEM + CNG**,见 §5.6、§12):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[tls]
|
||||||
|
Enabled = 0 ; 是否启用传输层加密(0/1)
|
||||||
|
CertPath = "" ; 服务端证书路径(PEM,-----BEGIN CERTIFICATE-----)
|
||||||
|
KeyPath = "" ; 服务端私钥路径(PEM PKCS#8,-----BEGIN PRIVATE KEY-----)
|
||||||
|
```
|
||||||
|
|
||||||
|
三键都是**短字符串路径**,不落 PEM 正文,故不受 `iniFile::GetStr` 512 字节固定缓冲限制(该限制只在「存 PEM 正文」时才成问题,客户端已为此单独绕行,见上游 §6.1)。
|
||||||
|
|
||||||
|
### 4.2 未来不返工的四条硬约束(本次预埋)
|
||||||
|
|
||||||
|
1. **配置单一访问器**:TLS 开关与身份路径只经一个中心对象 `ServerTlsIdentity` 暴露:`Load(config&)` / `IsEnabled()` / `IsReady()` / `IsLoadError()` / `Cred()`。未来 UI 改配置、指令查询「我当前是否加密态」都走这同一个入口,禁止代码里散落 `GetInt("Enabled")`。
|
||||||
|
2. **身份加载做成可重入,绝不用 `std::call_once`**:`ServerTlsIdentity::Load(config&)` 每次调用重新加载证书+私钥并重建凭据句柄。本次启动时调一次;未来「轮换证书 / 开关切换」可再调。这是对客户端 `TlsEnabled()` 用 `std::call_once` 一次读死、不可热切换(见上游 §6.1 与 `docs/tls-cert-push-revoke-deferred` 相关记忆)的**主动规避**。
|
||||||
|
3. **「我是否 TLS 就绪」是全局只读态,与每条连接的 TLS 态分开**:`ServerTlsIdentity::IsEnabled()`(全局配置态)/ `IsReady()`(身份加载成功)与 per-connection 的「该连接是否走 TLS/握手是否完成」是两个概念。未来指令/UI 只碰前者。
|
||||||
|
4. **按连接嗅探本身就是为混合上线准备的**:服务端开 TLS 后,没切换的旧主机照样明文连、已切换的走 TLS。未来「下发开启指令」只改客户端行为,服务端传输层不用动。
|
||||||
|
|
||||||
|
## 5. 服务端改动详述
|
||||||
|
|
||||||
|
### 5.1 `CONTEXT_OBJECT` 字段(`Server.h`,加 4 项)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// 每连接传输层状态(单向 TLS,见 §5.2);跨线程(握手线程写 TLS_Ready,工作线程读)用 atomic 保序
|
||||||
|
enum ServerTlsState {
|
||||||
|
TLS_Unclassified = 0, // 尚未收到足够字节判定(缓冲 tlsPreClassify)
|
||||||
|
TLS_Plain = 1, // 明文客户端,直通现有解析路径
|
||||||
|
TLS_Handshaking = 2, // TLS 客户端,握手线程阻塞 AcceptSecurityContext 中
|
||||||
|
TLS_Ready = 3, // TLS 握手完成,收包解密 / 发包加密
|
||||||
|
};
|
||||||
|
ServerTlsSession* tls = nullptr; // 懒分配;握手完成后承载加解密状态
|
||||||
|
std::atomic<int> tlsState{ TLS_Unclassified }; // ServerTlsState
|
||||||
|
std::vector<BYTE> tlsPreClassify; // 分类前缓冲的首批字节(嗅探 ClientHello 头)
|
||||||
|
bool bSendPending = false; // 发送在途抑制(见 §5.5);受 SendLock 保护
|
||||||
|
|
||||||
|
void FreeTls(); // delete tls; tls = NULL;(定义在 ServerTlsSession.cpp,避免本头拖入 Schannel)
|
||||||
|
```
|
||||||
|
|
||||||
|
生命周期见 §5.7:`InitMember` 时复位 `tlsState`/`tlsPreClassify`/`tls`,析构 / 回收时 `FreeTls()`。
|
||||||
|
|
||||||
|
### 5.2 嗅探与预分类缓冲
|
||||||
|
|
||||||
|
首次 `IORead` 完成时(`OnClientReceiving` 开头,`ParseReceivedData` 之前)判定:
|
||||||
|
|
||||||
|
- `dwTrans >= 6`:看 `szBuffer[0..5]`,一次定死明文/TLS;
|
||||||
|
- `dwTrans < 6`(TCP 把 ClientHello 头分片,极罕见但须正确):把这 `dwTrans` 字节追加进 `tlsPreClassify`,`PostRecv` 继续收,直到攒够 6 字节再判定。判定后清空 `tlsPreClassify`。
|
||||||
|
|
||||||
|
明文连接判定后,把 `tlsPreClassify`(如有)+ 本次 `szBuffer` 一并按现状喂给 `ParseReceivedData`,保证分片字节不丢。
|
||||||
|
|
||||||
|
### 5.3 握手(已拍板方案 A)
|
||||||
|
|
||||||
|
服务端握手需 `AcceptSecurityContext`(Schannel 入站),与客户端 `InitializeSecurityContext`(出站)**不通用**,需新写。数据阶段的 `DecryptMessage`/`EncryptMessage` 是同步缓冲变换,与异步收发包天然兼容,两个方案都复用。
|
||||||
|
|
||||||
|
**方案 A:专用握手线程(阻塞式,已实施)**
|
||||||
|
- 首次 `IORead` 判定为 TLS 后,此刻 `WSARecv` 已因完成回调结束、socket 上**无 pending overlapped 操作**,可安全转交一个短命线程做阻塞握手。
|
||||||
|
- 线程内用**与客户端 `TlsChannel::Handshake` 同型的阻塞 Schannel 循环**跑 `AcceptSecurityContext`(`TlsChannel.cpp` 已证明该模式可靠),把已收到的 ClientHello 字节(`tlsPreClassify`,可能是分片)作为初始输入,后续 handshake 航程走阻塞 `recv`/`send`;握手前把 socket 切回阻塞态并设 15s 收发超时。
|
||||||
|
- 成功 → `tlsState.store(TLS_Ready)`,`PostRecv` 投递首个 `IORead`,回到 IOCP 正常流程;失败 → 关连接(fail-closed,§8)。
|
||||||
|
- 优点:复用客户端已验证的阻塞 Schannel 代码形态、状态最少;缺点:每个 TLS 连接多一个短命线程(仅在握手期间存活)。
|
||||||
|
|
||||||
|
**方案 B:异步状态机(无额外线程,更贴合 IOCP)**
|
||||||
|
- 握手由 `IORead`/`IOWrite` 完成回调推进:每次回调喂一段握手字节给 `AcceptSecurityContext`,按其返回(`SEC_I_CONTINUE_NEEDED` → 继续收/发;`SEC_E_OK` → 完成)驱动下一动作。
|
||||||
|
- 优点:无额外线程、与现有 IOCP 事件循环同构;缺点:状态管理明显更重,需把「握手待收/待发/完成」编码进 per-connection 状态。
|
||||||
|
|
||||||
|
**已拍板(2026-10-14)**:采用 **方案 A**(简单、复用客户端阻塞 Schannel 经验、风险低)。方案 B 留作后续压测发现短命线程开销不可接受时再演进;二者切换只影响 §5.3,不影响 ①②④ 的过滤器设计。
|
||||||
|
|
||||||
|
### 5.4 收包解密(接缝 ①,`ParseReceivedData` 之上)
|
||||||
|
|
||||||
|
`OnClientReceiving` 里,明文与 TLS 分叉:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
int st = ContextObject->tlsState.load(std::memory_order_acquire);
|
||||||
|
if (st == TLS_Ready) {
|
||||||
|
// 1) 把 szBuffer[0..dwTrans] 喂给 DecryptMessage,得到明文 plain、长度 n
|
||||||
|
// 2) 处理 SECBUFFER_EXTRA:多余密文留存到 tls 对象内部缓冲(m_raw),下次续解(见上游 §6.5)
|
||||||
|
// 3) plain 为空(记录分片未齐)→ PostRecv 继续收;否则 ContextObject->InCompressedBuffer.WriteBuffer(plain.data(), n);
|
||||||
|
} else {
|
||||||
|
ContextObject->InCompressedBuffer.WriteBuffer(szBuffer, dwTrans); // 现状,零改动
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
即 ① 从「一行直写」变成「条件分支」,明文侧原文不动。解密缓冲重组(`STREAM_HEADER/DATA/TRAILER/EXTRA` 四段布局)已在上游 §6.5 踩过坑、给出正确写法,服务端直接沿用(`ServerTlsSession::Decrypt` 内维护 `m_raw` 续解缓冲)。
|
||||||
|
|
||||||
|
### 5.5 发包加密(接缝 ②,`OnClientPreSending` 之上)
|
||||||
|
|
||||||
|
`OutCompressedBuffer` 是「可能残留上一轮未发完密文」的可变缓冲:异步 `WSASend` 在途时,新明文由 `WriteContextData` 追加在尾部。因此 `OnClientPreSending`(`IOCPServer.cpp`)**只加密本次新追加的尾部明文**,而非整个缓冲:
|
||||||
|
|
||||||
|
1. 追加前记 `oldLen = OutCompressedBuffer.GetBufferLength()`;
|
||||||
|
2. `WriteContextData` 追加新明文后,`plainLen = totalLen - oldLen`,只对 `GetBuffer(oldLen)` 起的 `plainLen` 字节调用 `tls->Encrypt` 得密文 `wire`;
|
||||||
|
3. `TruncateBuffer(oldLen)` 回退尾部明文(**不用 `ClearBuffer`**:后者会 `DeAllocateBuffer`,可能 `VirtualFree` 正在被在途 `WSASend` 引用的底层内存),再 `WriteBuffer(wire)` 追加新密文。
|
||||||
|
|
||||||
|
这样 `OutCompressedBuffer` 始终保持一串合法 TLS 记录。若把整个缓冲重新加密,会把旧密文二次加密,客户端解密后得到「旧 TLS 记录」→ `Unknown header 17 03 03`。
|
||||||
|
|
||||||
|
**发送唤醒的并发抑制(`bSendPending`)**:`OnClientPreSending` 每次 `Send2Client` 若都 `PostQueuedCompletionStatus(IOWrite, 0)`,则每个 `OnClientPostSending(ctx, 0)` 都会对整个缓冲再发起一次 `WSASend`;背靠背小包(终端 `resize → COMMAND_NEXT → resize`)会触发多个并发 `WSASend` 读到同一段可变内存,产出重复/错位密文(客户端 `DecryptMessage` 失败,`WSAGetLastError()` 残留 `6001 = ERROR_DECRYPTION_FAILED`)。故新增 `bool bSendPending`(受 `SendLock` 保护):仅在 `!bSendPending` 时投递 IOWrite 并置位;`OnClientPostSending` 在缓冲排空(最后一个 `WSASend` 完成)或发送失败时清零;`InitMember` 复用对象池时复位。在途 `WSASend` 的完成回调会看到排空后仍残留的新数据并继续发送,无需再投递。后续 `OnClientPostSending` 的部分发送循环照旧——它发送的已是密文字节,逐段 `WSASend` 天然成立。
|
||||||
|
|
||||||
|
### 5.6 证书/私钥加载(Schannel 入站凭据,已拍板 PEM+CNG)
|
||||||
|
|
||||||
|
`AcquireCredentialsHandle` 取 `SCHANNEL_CRED`(入站)时需一个**已绑定私钥的证书**。已拍板用 **PEM + CNG**(与 Go 端 `YAMA_TLS_CERT/KEY` 同构,服务端与 Go 共用同一对密钥):
|
||||||
|
|
||||||
|
- **证书**:`CryptStringToBinaryA(CRYPT_STRING_ANY)` 把 PEM 解为 DER → `CertCreateCertificateContext(X509_ASN_ENCODING)`。
|
||||||
|
- **私钥(仅 PKCS#8)**:Schannel 入站凭据要求私钥是**「能按名重开」的持久化键**——`NCryptImportKey` 产出的是匿名临时键,绑定 `CERT_NCRYPT_KEY_HANDLE_PROP_ID` 后 Schannel 仍拿不到(`AcquireCredentialsHandle` 返回 `SEC_E_NO_CREDENTIALS` `0x8009030e`)。因此:
|
||||||
|
1. **导入临时键并导出裸 BLOB**:`NCryptOpenStorageProvider(MS_KEY_STORAGE_PROVIDER)` + `NCryptImportKey(NCRYPT_PKCS8_PRIVATE_KEY_BLOB)` 得临时键(`hProvider` 不能传 NULL——默认提供者解析实测返回 `NTE_INVALID_HANDLE` `0x80090026`,须显式打开 KSP);再 `NCryptSetProperty(NCRYPT_EXPORT_POLICY_PROPERTY=ALLOW_PLAINTEXT_EXPORT|ALLOW_EXPORT)` 后 `NCryptExportKey(BCRYPT_RSAFULLPRIVATE_BLOB)` 得裸 RSA 私钥 BLOB。
|
||||||
|
2. **写入持久化键**:`NCryptCreatePersistedKey(BCRYPT_RSA_ALGORITHM, "YamaServerTlsKey", AT_KEYEXCHANGE, NCRYPT_OVERWRITE_KEY_FLAG)` → `NCryptSetProperty(BCRYPT_RSAFULLPRIVATE_BLOB)` → `NCryptFinalizeKey`。`NCryptSetProperty` 只认裸 BLOB、不认 PKCS#8(直接设 `NCRYPT_PKCS8_PRIVATE_KEY_BLOB` 实测 `NTE_NOT_SUPPORTED` `0x80090029`)。
|
||||||
|
3. **按名绑定**:`CertSetCertificateContextProperty(CERT_KEY_PROV_INFO_PROP_ID, CRYPT_KEY_PROV_INFO{ pwszContainerName="YamaServerTlsKey", pwszProvName=MS_KEY_STORAGE_PROVIDER, dwProvType=0, dwKeySpec=AT_KEYEXCHANGE })`。Schannel 经 `CryptAcquireCertificatePrivateKey` 按名在 KSP 里重开该键。
|
||||||
|
- **入站凭据**:`AcquireCredentialsHandleA("Microsoft Unified Security Protocol Provider", SECPKG_CRED_INBOUND, &SCHANNEL_CRED{ cCreds=1, paCred=&cert, grbitEnabledProtocols=SP_PROT_TLS1_2_SERVER, dwFlags=NO_DEFAULT_CREDS })`。
|
||||||
|
|
||||||
|
关键点:**私钥必须是 PKCS#8**(`-----BEGIN PRIVATE KEY-----`);若拿到的是 PKCS#1(`-----BEGIN RSA/EC PRIVATE KEY-----`),用
|
||||||
|
`openssl pkcs8 -topk8 -nocrypt -in server.key -out server.key` 转换。证书上下文生命周期交给进程级 `g_cert`,持久化键句柄交给 `g_persistedKey`;`FreeIdentity` 里按序 `FreeCredentialsHandle` → `CertFreeCertificateContext` → `NCryptDeleteKey`(`NCryptDeleteKey` 会同时释放句柄,勿再 `NCryptFreeObject`)。
|
||||||
|
|
||||||
|
### 5.7 生命周期(context 池化复用)
|
||||||
|
|
||||||
|
`CONTEXT_OBJECT` 经 `AllocateContext`(`:1311`)从 `m_ContextFreePoolList` **复用**、`InitMember`(`:1314`)复位。故 TLS 状态必须在 `InitMember` 里清空 `tlsPreClassify`、`FreeTls()`、复位 `tlsState`(`store(TLS_Unclassified)`)与 `bSendPending=false`(避免上次连接残留的在途标志抑制新连接发送),并在 `~CONTEXT_OBJECT`(`Server.h:346`)`FreeTls()`。否则「前一个 TLS 连接的状态漏给下一个明文连接」会直接导致解析错乱。这是唯一需要跨过 `InitMember`/析构两处做的生命周期接线。
|
||||||
|
|
||||||
|
### 5.8 明确不动的部分
|
||||||
|
|
||||||
|
- `ParseReceivedData` 内的 `Parse`/解压/`Decode`、`WriteContextData` 内的 `Encode`/压缩/掩码:**零改动**。
|
||||||
|
- `HeaderParser` / `context`(`context.h`)协议层:**零改动**。
|
||||||
|
- `OnAccept` / `PostRecv` / `AllocateContext` / `RemoveStaleContext` 主结构:**零改动**(仅新增 TLS 分支与字段复位)。
|
||||||
|
- KCP / UDP 传输(`IOCPKCPServer`):**零改动**(不在本期范围)。
|
||||||
|
|
||||||
|
## 6. 新老客户端兼容矩阵
|
||||||
|
|
||||||
|
| 客户端 | 服务端 TLS 状态 | 服务端行为 | 结果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 老客户端(无 TLS 概念) | 未配置 | 明文路径 | 与现状完全一致 |
|
||||||
|
| 老客户端 | 已配置 | 嗅探判为明文 → 明文路径 | 与现状完全一致(透明) |
|
||||||
|
| 新客户端 · 无证书 | 未配置 / 已配置 | 明文路径 | 与老客户端一致(默认) |
|
||||||
|
| 新客户端 · 有证书 | 已配置 | 嗅探判为 TLS → 握手 → 加密 | 全量加密 |
|
||||||
|
| 新客户端 · 有证书 | 未配置 | 嗅探判为 TLS → 无证书可握手 → **关连接(fail-closed)** | 拒绝(见 §8) |
|
||||||
|
|
||||||
|
## 7. 与未来「下发/撤销指令 + UI」的接缝
|
||||||
|
|
||||||
|
本次不实现 UI 与指令,但通过 §4.2 四条约束预留干净接缝。二者未来的分工与依赖:
|
||||||
|
|
||||||
|
- **UI**:在 MFC 配置对话框(类 MCP)里加「启用传输加密 + 指定一对密钥」,写入 `[tls]` 三键 → 调 `ServerTlsIdentity::Load(config&)` 重载 → `IsEnabled()` 即时生效。**只碰 §4.2 第 1/2 点,不碰传输层。**
|
||||||
|
- **「向在线主机下发启用/撤销加密」指令**:服务端侧是一条新的命令通道(遍历在线连接广播),客户端侧是**运行时热切换**——收到指令后热加载/删除信任锚、断开重连走新通道。
|
||||||
|
|
||||||
|
**必须先说破的跨端依赖**:这条指令真正的大头在**客户端**——客户端目前读 `tls_cert` 是进程启动读一次、`std::call_once`、**不可热切换**(见上游 §6.1 及 `docs/tls-cert-push-revoke-deferred` 记忆)。因此「下发/撤销」落地时,客户端要先把 `TlsEnabled()` 的一次读死改成「指令驱动的热重载」。**这与本次服务端传输层正交**——只要 §4.2 第 2/3 点把服务端状态做成可重载、可中心化查询,未来就能干净接上,无需回头改本次代码。
|
||||||
|
|
||||||
|
## 8. 安全考量
|
||||||
|
|
||||||
|
- **TLS 提供**:机密性、完整性、服务端身份(客户端校验)、抗重放(TLS record 序号 + nonce)。对 TLS 客户端而言,Measure 1(真加密 + 防重放)达成,与 Go 端一致。
|
||||||
|
- **TLS 不提供**(单向):客户端身份——仍由隧道内登录承担。
|
||||||
|
- **服务端 fail-closed**:服务端收到 TLS ClientHello 但自身未配证书/身份加载失败 → **直接关连接**,禁止把 TLS 字节当协议去解析(否则新客户端握手失败重试 + 服务端狂报解析错)。与客户端 fail-closed 语义对称(见上游 §6.4)。
|
||||||
|
- **握手 DoS**:TLS 握手有 CPU 开销,须受 per-IP 限流(现有)保护;方案 A 的短命握手线程还须加握手超时(`SSPI` 握手期间设 socket 收发超时,超时关连接)。
|
||||||
|
- **部署顺序**(关键):**MFC 服务端证书必须与客户端 CA 一起(或先于)上线**。否则「新客户端(已钉 CA)连旧 MFC 服务端(无证书)」→ 客户端 fail-closed 直接拒绝,表现为「升级客户端后连不上老服务端」。这是设计使然,部署/文档须写明。
|
||||||
|
- **私钥安全**:服务器私钥只在握手时由 Schannel 内部签名使用(ECDHE)或解密预主密钥(RSA),不参与业务数据加解密;私钥文件应最小权限存放、不落日志。真正的长期秘密(CA 私钥)平时离线,仅在签发/轮换服务器证书时使用。
|
||||||
|
|
||||||
|
## 9. 部署与运维
|
||||||
|
|
||||||
|
生成服务端身份(与 Go 端 §9.1 的 `server.crt` + `server.key` 完全同一对密钥;私钥须 PKCS#8):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 若私钥是 PKCS#1(-----BEGIN RSA/EC PRIVATE KEY-----),先转 PKCS#8:
|
||||||
|
openssl pkcs8 -topk8 -nocrypt -in server.key -out server.key
|
||||||
|
```
|
||||||
|
|
||||||
|
MFC 服务端配置(直接指向与 Go 端相同的 PEM 文件):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[tls]
|
||||||
|
Enabled = 1
|
||||||
|
CertPath = C:\YAMA\server.crt
|
||||||
|
KeyPath = C:\YAMA\server.key
|
||||||
|
```
|
||||||
|
|
||||||
|
重启 MFC 服务端即生效(本次仅启动时读一次,热切换待未来 UI)。删除 `[tls]` 节或置 `Enabled=0` 即回纯明文。
|
||||||
|
|
||||||
|
## 10. 测试计划
|
||||||
|
|
||||||
|
1. **嗅探单元**:`0x16 0x03 .. 0x01`(TLS 1.2/1.3 ClientHello)/ 明文首包 / 混淆头(首两字节 `0x16 0x03` 但 `b[5]!=0x01`)/ 首包 <6 字节分片 的判定。
|
||||||
|
2. **TLS 未配置**:`Enabled=0` 或无证书 → 纯明文,行为与现状一致(老 + 新无证书客户端回归)。
|
||||||
|
3. **明文回归**:老客户端全链路(登录/屏幕/终端/文件)不回归。
|
||||||
|
4. **TLS 加密**:新客户端(有证书)连 MFC 服务端 → 握手成功,登录/屏幕/终端/文件子连接全部在 TLS 内跑通;抓包确认 payload 不可读。
|
||||||
|
5. **混合并发**:明文与 TLS 客户端同时在线,互不干扰(对应未来过渡期)。
|
||||||
|
6. **fail-closed 双向**:① 服务端未配证书但收到 ClientHello → 关连接不误解析;② 客户端有证书但服务端证书不匹配 → 客户端断开不回退明文。
|
||||||
|
7. **握手超时 / 限流**:恶意慢握手被超时 + per-IP 限流压制。
|
||||||
|
8. **连接复用**:一条 TLS 连接断开后,context 回池再被明文连接复用,TLS 状态已复位、无泄漏(§5.7)。
|
||||||
|
|
||||||
|
## 11. 分期与回滚
|
||||||
|
|
||||||
|
- **本期(MFC 服务端传输加密,已实施)**:`ServerTlsIdentity`(PEM+CNG 加载)+ `CONTEXT_OBJECT` 字段(`tlsState` 枚举)+ 嗅探/预分类 + 握手(方案 A)+ ①收包解密 + ②发包加密 + 生命周期复位。可独立验证——用 `openssl s_client` 或复用 Go 端测试客户端联调,无需真实客户端配合。
|
||||||
|
- **后续 A(UI)**:MCP 式配置对话框 + 重载 `ServerTlsIdentity`。
|
||||||
|
- **后续 B(下发/撤销指令)**:服务端指令通道 + **客户端热切换**(客户端 `TlsEnabled()` 改指令驱动,见 §7)。
|
||||||
|
- **回滚**:服务端 `[tls] Enabled=0` 即回纯明文,无需改客户端(客户端无证书时本就明文)。
|
||||||
|
|
||||||
|
## 12. 已拍板决策与仍待定问题
|
||||||
|
|
||||||
|
**已拍板(2026-10-14)**:
|
||||||
|
|
||||||
|
1. **握手方案**:采用 **方案 A(专用阻塞线程)**,见 §5.3。
|
||||||
|
2. **身份文件格式**:采用 **PEM + CNG(`CertPath` + `KeyPath`,私钥 PKCS#8)**,与 Go 服务端共用同一对密钥,见 §5.6。
|
||||||
|
3. **配置节名**:新建 **`[tls]` 节**,键 `Enabled` / `CertPath` / `KeyPath`,见 §4.1。
|
||||||
|
|
||||||
|
**仍待定**:
|
||||||
|
|
||||||
|
1. **KCP/UDP 传输是否也要 TLS**:本期不含,另行评估。
|
||||||
|
2. **MFC 的 MCP 式配置 UI**:显式排除,见 §7;落地时只碰 §4.2 第 1/2 点。
|
||||||
Binary file not shown.
@@ -50,6 +50,8 @@
|
|||||||
#include "CFileSpeedDlg.h"
|
#include "CFileSpeedDlg.h"
|
||||||
#include "McpServer.h"
|
#include "McpServer.h"
|
||||||
#include "McpSettingsDlg.h"
|
#include "McpSettingsDlg.h"
|
||||||
|
#include "TlsCertificateDlg.h"
|
||||||
|
#include "ServerTlsSession.h"
|
||||||
#include "common/md5.h"
|
#include "common/md5.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <set>
|
#include <set>
|
||||||
@@ -147,23 +149,24 @@ const int g_Column_Count_Online = ONLINELIST_MAX + 1; // 1 缩略图 + 既有
|
|||||||
|
|
||||||
COLUMNSTRUCT g_Column_Data_Online[g_Column_Count_Online] = {
|
COLUMNSTRUCT g_Column_Data_Online[g_Column_Count_Online] = {
|
||||||
{"预览", 60 },
|
{"预览", 60 },
|
||||||
{"IP", 130 },
|
{"IP", 120 },
|
||||||
{"端口", 60 },
|
{"端口", 55 },
|
||||||
{"地理位置", 140 },
|
{"地理位置", 140 },
|
||||||
{"计算机名/备注", 140 },
|
{"计算机名/备注", 140 },
|
||||||
{"操作系统", 120 },
|
{"操作系统", 135 },
|
||||||
{"CPU", 80 },
|
{"CPU", 75},
|
||||||
{"摄像头", 60 },
|
{"摄像头", 55 },
|
||||||
{"RTT", 60 },
|
{"RTT", 55 },
|
||||||
{"版本", 90 },
|
{"版本", 90 },
|
||||||
{"安装时间", 130 },
|
{"安装时间", 130 },
|
||||||
{"活动窗口", 130 },
|
{"活动窗口", 145 },
|
||||||
{"类型", 50 },
|
{"类型", 50 },
|
||||||
{"", 0 },
|
{"", 0 },
|
||||||
{"", 0 },
|
{"", 0 },
|
||||||
{"", 0 },
|
{"", 0 },
|
||||||
{"", 0 },
|
{"", 0 },
|
||||||
{"权限", 60 },
|
{"权限", 60 },
|
||||||
|
{"TLS", 50 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// 缩略图列号常量 + 列号→数据槽映射。负返回值代表"该列无数据槽"。
|
// 缩略图列号常量 + 列号→数据槽映射。负返回值代表"该列无数据槽"。
|
||||||
@@ -737,6 +740,8 @@ CMy2015RemoteDlg::CMy2015RemoteDlg(CWnd* pParent): CDialogLangEx(CMy2015RemoteDl
|
|||||||
m_bmOnline[66].LoadBitmap(IDB_BITMAP_FILE_MANAGER); // 文件管理
|
m_bmOnline[66].LoadBitmap(IDB_BITMAP_FILE_MANAGER); // 文件管理
|
||||||
m_bmOnline[67].LoadBitmap(IDB_BITMAP_PRIVACY_SHIELD); // 隐私屏幕
|
m_bmOnline[67].LoadBitmap(IDB_BITMAP_PRIVACY_SHIELD); // 隐私屏幕
|
||||||
m_bmOnline[68].LoadBitmap(IDB_BITMAP_DRAWING_BOARD); // 远程画板
|
m_bmOnline[68].LoadBitmap(IDB_BITMAP_DRAWING_BOARD); // 远程画板
|
||||||
|
m_bmOnline[69].LoadBitmap(IDB_BITMAP_TLS_ENABLE); // TLS 传输
|
||||||
|
m_bmOnline[70].LoadBitmap(IDB_BITMAP_TLS_DISABLE); // 取消 TLS
|
||||||
for (int i = 0; i < PAYLOAD_MAXTYPE; i++) {
|
for (int i = 0; i < PAYLOAD_MAXTYPE; i++) {
|
||||||
m_ServerDLL[i] = nullptr;
|
m_ServerDLL[i] = nullptr;
|
||||||
m_ServerBin[i] = nullptr;
|
m_ServerBin[i] = nullptr;
|
||||||
@@ -1031,6 +1036,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
|
|||||||
ON_COMMAND(ID_MSGLOG_LOGIN_NOTIFY, &CMy2015RemoteDlg::OnMsglogLoginNotify)
|
ON_COMMAND(ID_MSGLOG_LOGIN_NOTIFY, &CMy2015RemoteDlg::OnMsglogLoginNotify)
|
||||||
ON_COMMAND(ID_MSGLOG_BAN_IP, &CMy2015RemoteDlg::OnMsglogBanIP)
|
ON_COMMAND(ID_MSGLOG_BAN_IP, &CMy2015RemoteDlg::OnMsglogBanIP)
|
||||||
ON_COMMAND(ID_MSGLOG_UNBAN_IP, &CMy2015RemoteDlg::OnMsglogUnbanIP)
|
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_ADD_WATCH, &CMy2015RemoteDlg::OnOnlineAddWatch)
|
||||||
ON_COMMAND(ID_ONLINE_LOGIN_NOTIFY, &CMy2015RemoteDlg::OnOnlineLoginNotify)
|
ON_COMMAND(ID_ONLINE_LOGIN_NOTIFY, &CMy2015RemoteDlg::OnOnlineLoginNotify)
|
||||||
ON_NOTIFY(NM_CUSTOMDRAW, IDC_ONLINE, &CMy2015RemoteDlg::OnNMCustomdrawOnline)
|
ON_NOTIFY(NM_CUSTOMDRAW, IDC_ONLINE, &CMy2015RemoteDlg::OnNMCustomdrawOnline)
|
||||||
@@ -1059,12 +1065,15 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
|
|||||||
ON_COMMAND(ID_TOOL_RELOAD_PLUGINS, &CMy2015RemoteDlg::OnToolReloadPlugins)
|
ON_COMMAND(ID_TOOL_RELOAD_PLUGINS, &CMy2015RemoteDlg::OnToolReloadPlugins)
|
||||||
ON_COMMAND(ID_TOOL_PLUGIN_SETTINGS, &CMy2015RemoteDlg::OnToolPluginSettings)
|
ON_COMMAND(ID_TOOL_PLUGIN_SETTINGS, &CMy2015RemoteDlg::OnToolPluginSettings)
|
||||||
ON_COMMAND(ID_MCP_SETTINGS, &CMy2015RemoteDlg::OnMcpSettings)
|
ON_COMMAND(ID_MCP_SETTINGS, &CMy2015RemoteDlg::OnMcpSettings)
|
||||||
|
ON_COMMAND(ID_TLS_CERTIFICATE, &CMy2015RemoteDlg::OnTlsCertificate)
|
||||||
ON_COMMAND(ID_CONNECTION_MANAGE, &CMy2015RemoteDlg::OnConnectionManage)
|
ON_COMMAND(ID_CONNECTION_MANAGE, &CMy2015RemoteDlg::OnConnectionManage)
|
||||||
ON_COMMAND(ID_TALK_MANAGE, &CMy2015RemoteDlg::OnChat)
|
ON_COMMAND(ID_TALK_MANAGE, &CMy2015RemoteDlg::OnChat)
|
||||||
ON_COMMAND(ID_MACHINE_MANAGE, &CMy2015RemoteDlg::OnMachineManager)
|
ON_COMMAND(ID_MACHINE_MANAGE, &CMy2015RemoteDlg::OnMachineManager)
|
||||||
ON_COMMAND(ID_EXPERIMENT_FILE_MANAGER, &CMy2015RemoteDlg::OnExperimentFileManager)
|
ON_COMMAND(ID_EXPERIMENT_FILE_MANAGER, &CMy2015RemoteDlg::OnExperimentFileManager)
|
||||||
ON_COMMAND(ID_EXPERIMENT_PRIVATE_SCREEN, &CMy2015RemoteDlg::OnExperimentPrivateScreen)
|
ON_COMMAND(ID_EXPERIMENT_PRIVATE_SCREEN, &CMy2015RemoteDlg::OnExperimentPrivateScreen)
|
||||||
ON_COMMAND(ID_EXPERIMENT_DRAWING_BOARD, &CMy2015RemoteDlg::OnExperimentDrawingBoard)
|
ON_COMMAND(ID_EXPERIMENT_DRAWING_BOARD, &CMy2015RemoteDlg::OnExperimentDrawingBoard)
|
||||||
|
ON_COMMAND(ID_EXPERIMENT_TLS_ENABLE, &CMy2015RemoteDlg::OnExperimentTlsEnable)
|
||||||
|
ON_COMMAND(ID_EXPERIMENT_TLS_DISABLE, &CMy2015RemoteDlg::OnExperimentTlsDisable)
|
||||||
ON_COMMAND(ID_TRIGGER_SETTINGS, &CMy2015RemoteDlg::OnTriggerSettings)
|
ON_COMMAND(ID_TRIGGER_SETTINGS, &CMy2015RemoteDlg::OnTriggerSettings)
|
||||||
ON_COMMAND(ID_SHELLCODE_AES_C_ARRAY, &CMy2015RemoteDlg::OnShellcodeAesCArray)
|
ON_COMMAND(ID_SHELLCODE_AES_C_ARRAY, &CMy2015RemoteDlg::OnShellcodeAesCArray)
|
||||||
ON_COMMAND(ID_PARAM_KBLOGGER, &CMy2015RemoteDlg::OnParamKblogger)
|
ON_COMMAND(ID_PARAM_KBLOGGER, &CMy2015RemoteDlg::OnParamKblogger)
|
||||||
@@ -1323,6 +1332,17 @@ VOID CMy2015RemoteDlg::CreateSolidMenu()
|
|||||||
ID_MCP_SETTINGS, _TR("MCP设置(&M)..."));
|
ID_MCP_SETTINGS, _TR("MCP设置(&M)..."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 动态插入「TLS证书」菜单项,位于「MCP设置」之后、「插件设置」之前(对同一锚点
|
||||||
|
// ID_TOOL_PLUGIN_SETTINGS 二次 InsertMenu,后插者更贴近锚点,故排在 MCP 下方)。
|
||||||
|
if (!SHOULD_HIDE_MENU(HIDE_MENU_TLS_CERTIFICATE, MF_TLS_CERTIFICATE)) {
|
||||||
|
UINT tlsFlags = MF_BYCOMMAND;
|
||||||
|
if (THIS_CFG.GetInt("tls", "Enabled", 0)) {
|
||||||
|
tlsFlags |= MF_CHECKED; // 已启用 TLS 则菜单打勾
|
||||||
|
}
|
||||||
|
pExtMenu->InsertMenu(ID_TOOL_PLUGIN_SETTINGS, tlsFlags,
|
||||||
|
ID_TLS_CERTIFICATE, _TR("TLS证书(&T)..."));
|
||||||
|
}
|
||||||
|
|
||||||
// 将 3 个插件相关菜单项收拢到「插件」二级菜单(位于「MCP设置」之下、原「插件设置」之前,
|
// 将 3 个插件相关菜单项收拢到「插件」二级菜单(位于「MCP设置」之下、原「插件设置」之前,
|
||||||
// 顺序不变:插件设置 / 刷新插件 / 获取插件)。
|
// 顺序不变:插件设置 / 刷新插件 / 获取插件)。
|
||||||
// 注意:先删除顶层项、再按位置插入子菜单,避免同一命令 ID 同时存在于子菜单与顶层时,
|
// 注意:先删除顶层项、再按位置插入子菜单,避免同一命令 ID 同时存在于子菜单与顶层时,
|
||||||
@@ -1661,10 +1681,11 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
|
|||||||
const std::string& privUser = v[RES_USERNAME];
|
const std::string& privUser = v[RES_USERNAME];
|
||||||
const std::string& privAdmin = v[RES_ISADMIN];
|
const std::string& privAdmin = v[RES_ISADMIN];
|
||||||
CString privilegeStr = privUser == "SYSTEM" ? "SYSTEM" : privAdmin == "1" ? "Admin" : "User";
|
CString privilegeStr = privUser == "SYSTEM" ? "SYSTEM" : privAdmin == "1" ? "Admin" : "User";
|
||||||
|
CString tlsStr = v[RES_TLS].empty() ? "N" : v[RES_TLS] == "1" ? "Y" : "N";
|
||||||
|
|
||||||
CString data[ONLINELIST_MAX] = { strIP, strAddr, "", strPCName, strOS, strCPU, strVideo, strPing,
|
CString data[ONLINELIST_MAX] = { strIP, strAddr, "", strPCName, strOS, strCPU, strVideo, strPing,
|
||||||
verDisplay, install, startTime, v[RES_CLIENT_TYPE].empty() ? "?" : v[RES_CLIENT_TYPE].c_str(), path,
|
verDisplay, install, startTime, v[RES_CLIENT_TYPE].empty() ? "?" : v[RES_CLIENT_TYPE].c_str(), path,
|
||||||
v[RES_CLIENT_PUBIP].empty() ? strIP : v[RES_CLIENT_PUBIP].c_str(), startTime, capStr, privilegeStr,
|
v[RES_CLIENT_PUBIP].empty() ? strIP : v[RES_CLIENT_PUBIP].c_str(), startTime, capStr, privilegeStr, tlsStr,
|
||||||
};
|
};
|
||||||
// 优先采用客户端自报的 ID(新客户端用 V2 算法 = MachineGuid + 归一化路径,
|
// 优先采用客户端自报的 ID(新客户端用 V2 算法 = MachineGuid + 归一化路径,
|
||||||
// 比服务端按老算法 IP+PC+OS+CPU+PATH 重算更稳定)。
|
// 比服务端按老算法 IP+PC+OS+CPU+PATH 重算更稳定)。
|
||||||
@@ -1735,6 +1756,32 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!ctx->GetClientData(ONLINELIST_IP).IsEmpty()) {
|
if (!ctx->GetClientData(ONLINELIST_IP).IsEmpty()) {
|
||||||
|
// 诊断:client_id 相同,但主机名/用户名不同 → 很可能是「同镜像克隆机」
|
||||||
|
// (MachineGuid 相同 → id 相同,实为不同物理机)。IP 会随 DHCP/公网出口漂移,仅供参考。
|
||||||
|
// 写日志供管理员核对,作为后续「重复 id 主动下发新 client_id」的依据。
|
||||||
|
CString newUser = v[RES_USERNAME].c_str();
|
||||||
|
CString oldUser = ctx->GetAdditionalData(RES_USERNAME);
|
||||||
|
CString oldName = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
|
||||||
|
CString oldIP = ctx->GetClientData(ONLINELIST_IP);
|
||||||
|
if (strPCName.CompareNoCase(oldName) != 0 ||
|
||||||
|
data[ONLINELIST_IP].CompareNoCase(oldIP) != 0 ||
|
||||||
|
newUser.CompareNoCase(oldUser) != 0) {
|
||||||
|
Mprintf("WARN: client_id 相同但主机信息不同(疑似克隆机撞 ID)id=%llu\n"
|
||||||
|
" 新上线: 主机名=%s IP=%s 用户名=%s\n"
|
||||||
|
" 已在线: 主机名=%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);
|
Mprintf("上线消息 - 主机重复上线,暂存替补 [2]: %llu. IP= %s. Path= %s\n", id, data[ONLINELIST_IP], path);
|
||||||
// 网络抖动导致客户端用新连接重复上线:这里不立即替换/关闭旧连接,
|
// 网络抖动导致客户端用新连接重复上线:这里不立即替换/关闭旧连接,
|
||||||
// 否则旧连接被关会触发客户端反复重连抖动(更新前就一直在"替换旧连接")。
|
// 否则旧连接被关会触发客户端反复重连抖动(更新前就一直在"替换旧连接")。
|
||||||
@@ -4517,6 +4564,8 @@ void CMy2015RemoteDlg::OnNMRClickOnline(NMHDR *pNMHDR, LRESULT *pResult)
|
|||||||
Menu.SetMenuItemBitmaps(ID_EXPERIMENT_FILE_MANAGER, MF_BYCOMMAND, &m_bmOnline[66], &m_bmOnline[66]);
|
Menu.SetMenuItemBitmaps(ID_EXPERIMENT_FILE_MANAGER, MF_BYCOMMAND, &m_bmOnline[66], &m_bmOnline[66]);
|
||||||
Menu.SetMenuItemBitmaps(ID_EXPERIMENT_PRIVATE_SCREEN, MF_BYCOMMAND, &m_bmOnline[67], &m_bmOnline[67]);
|
Menu.SetMenuItemBitmaps(ID_EXPERIMENT_PRIVATE_SCREEN, MF_BYCOMMAND, &m_bmOnline[67], &m_bmOnline[67]);
|
||||||
Menu.SetMenuItemBitmaps(ID_EXPERIMENT_DRAWING_BOARD, MF_BYCOMMAND, &m_bmOnline[68], &m_bmOnline[68]);
|
Menu.SetMenuItemBitmaps(ID_EXPERIMENT_DRAWING_BOARD, MF_BYCOMMAND, &m_bmOnline[68], &m_bmOnline[68]);
|
||||||
|
Menu.SetMenuItemBitmaps(ID_EXPERIMENT_TLS_ENABLE, MF_BYCOMMAND, &m_bmOnline[69], &m_bmOnline[69]);
|
||||||
|
Menu.SetMenuItemBitmaps(ID_EXPERIMENT_TLS_DISABLE, MF_BYCOMMAND, &m_bmOnline[70], &m_bmOnline[70]);
|
||||||
|
|
||||||
Menu.ModifyMenuL(ID_ONLINE_AUTHORIZE, MF_BYCOMMAND | MF_STRING, ID_ONLINE_AUTHORIZE, _T("发送授权"));
|
Menu.ModifyMenuL(ID_ONLINE_AUTHORIZE, MF_BYCOMMAND | MF_STRING, ID_ONLINE_AUTHORIZE, _T("发送授权"));
|
||||||
|
|
||||||
@@ -4689,6 +4738,53 @@ void CMy2015RemoteDlg::OnExperimentDrawingBoard()
|
|||||||
SendSelectedCommand(&bToken, sizeof(BYTE));
|
SendSelectedCommand(&bToken, sizeof(BYTE));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CMy2015RemoteDlg::OnExperimentTlsEnable()
|
||||||
|
{
|
||||||
|
// 前置校验:服务端未启用 TLS 证书时,无信任锚可下发,仅提示、不下发指令。
|
||||||
|
if (!ServerTlsIdentity::IsReady()) {
|
||||||
|
MessageBoxL(_T("服务端未启用 TLS 证书,无法切换客户端 TLS 传输"),
|
||||||
|
_T("提示"), MB_ICONWARNING);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 二次确认:避免误点就切换被控端 TLS;客户端写注册表,下次启动生效
|
||||||
|
if (IDYES != MessageBoxL(_T("确定对选中主机启用 TLS 传输吗?(客户端下次启动生效)"),
|
||||||
|
_T("提示"), MB_ICONQUESTION | MB_YESNO)) return;
|
||||||
|
|
||||||
|
// 读取服务端证书(信任锚),随启用指令一并下发;客户端写入注册表 settings\tls_cert。
|
||||||
|
std::ifstream certFile(THIS_CFG.GetStr("tls", "CertPath", ""), std::ios::binary);
|
||||||
|
if (!certFile.is_open()) {
|
||||||
|
MessageBoxL(_T("读取服务端 TLS 证书失败,无法下发"), _T("提示"), MB_ICONWARNING);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::ostringstream ss;
|
||||||
|
ss << certFile.rdbuf();
|
||||||
|
std::string certPem = ss.str();
|
||||||
|
if (certPem.empty()) {
|
||||||
|
MessageBoxL(_T("读取服务端 TLS 证书失败,无法下发"), _T("提示"), MB_ICONWARNING);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 报文:命令字节 + PEM 正文
|
||||||
|
std::vector<BYTE> pkt;
|
||||||
|
pkt.reserve(1 + certPem.size());
|
||||||
|
pkt.push_back((BYTE)COMMAND_TLS_ENABLE);
|
||||||
|
pkt.insert(pkt.end(), certPem.begin(), certPem.end());
|
||||||
|
SendSelectedCommand(pkt.data(), (ULONG)pkt.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
void CMy2015RemoteDlg::OnExperimentTlsDisable()
|
||||||
|
{
|
||||||
|
if (!ServerTlsIdentity::IsReady()) {
|
||||||
|
MessageBoxL(_T("服务端未启用 TLS 证书,无法切换客户端 TLS 传输"),
|
||||||
|
_T("提示"), MB_ICONWARNING);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (IDYES != MessageBoxL(_T("确定取消选中主机的 TLS 传输吗?(客户端下次启动生效)"),
|
||||||
|
_T("提示"), MB_ICONQUESTION | MB_YESNO)) return;
|
||||||
|
BYTE bToken = COMMAND_TLS_DISABLE; //向被控端发送停用 TLS 传输命令
|
||||||
|
SendSelectedCommand(&bToken, sizeof(BYTE));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 从资源中读取被控端文件,并根据用户选择修改连接信息后返回给发送线程
|
// 从资源中读取被控端文件,并根据用户选择修改连接信息后返回给发送线程
|
||||||
BYTE* ReadExeFromResource(DWORD& outSize, int resourceId, const char* resName, int iType, int iStartup,
|
BYTE* ReadExeFromResource(DWORD& outSize, int resourceId, const char* resName, int iType, int iStartup,
|
||||||
@@ -6022,15 +6118,33 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
|||||||
}
|
}
|
||||||
case COMMAND_GET_FILE: {
|
case COMMAND_GET_FILE: {
|
||||||
// 发送文件
|
// 发送文件
|
||||||
std::string dir = (char*)(szBuffer + 1);
|
// 边界校验:dir 必须在 len 内以 NUL 结尾,否则 std::string 越界读,且
|
||||||
char* ptr = (char*)szBuffer + 1 + dir.length() + 1;
|
// len - 2 - dir.length() 会下溢导致后续越界读。异常/恶意客户端可能构造无 NUL 的包。
|
||||||
auto md5 = CalcMD5FromBytes((BYTE*)ptr, len - 2 - dir.length());
|
if (len < 2) {
|
||||||
if (!m_CmdList.PopCmd(md5)) {
|
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());
|
Mprintf("【警告】文件传输指令非法或已过期: %s\n", md5.c_str());
|
||||||
ContextObject->CancelIO();
|
ContextObject->CancelIO();
|
||||||
break;
|
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()) {
|
if (!files.empty()) {
|
||||||
CDlgFileSend* dlg = new CDlgFileSend(this, ContextObject->GetServer(), ContextObject, TRUE);
|
CDlgFileSend* dlg = new CDlgFileSend(this, ContextObject->GetServer(), ContextObject, TRUE);
|
||||||
dlg->Create(IDD_DIALOG_FILESEND, GetDesktopWindow());
|
dlg->Create(IDD_DIALOG_FILESEND, GetDesktopWindow());
|
||||||
@@ -7331,8 +7445,10 @@ std::pair<std::string, std::string> CMy2015RemoteDlg::GenerateRenewalInfo(
|
|||||||
newHmac = std::to_string(hmacVal);
|
newHmac = std::to_string(hmacVal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新 passcode 的到期日是“今天 + days” (见上面的 newPasscode);
|
||||||
|
// 预设日期已过去时 days 被钳制为 1,日志应显示实际日期。
|
||||||
Mprintf("[TOKEN_AUTH] 生成续期: %s, %s -> %s, %d并发数, 配额剩余%d\n",
|
Mprintf("[TOKEN_AUTH] 生成续期: %s, %s -> %s, %d并发数, 配额剩余%d\n",
|
||||||
sn.c_str(), currentExpire.c_str(), renewal.ExpireDate.c_str(), renewal.HostNum, renewal.Quota - 1);
|
sn.c_str(), currentExpire.c_str(), getDateStr(days).c_str(), renewal.HostNum, renewal.Quota - 1);
|
||||||
|
|
||||||
// 配额递减,配额用完后自动清除预设续期记录
|
// 配额递减,配额用完后自动清除预设续期记录
|
||||||
DecrementPendingQuota(sn);
|
DecrementPendingQuota(sn);
|
||||||
@@ -7432,13 +7548,18 @@ void CMy2015RemoteDlg::SendPendingRenewal(CONTEXT_OBJECT* ctx, const std::string
|
|||||||
int days = (int)span.GetDays();
|
int days = (int)span.GetDays();
|
||||||
if (days <= 0) days = 1;
|
if (days <= 0) days = 1;
|
||||||
|
|
||||||
|
// 客户端按“今天 + days”签发新授权(CMD_AUTHORIZATION 使用 getDateStr(0/days))。
|
||||||
|
// 预设日期若已经过去(过期授权在旧版本中设置的预设),实际到期日是今天 + 1,
|
||||||
|
// 日志和提示必须显示实际日期,而不是已失效的预设日期。
|
||||||
|
std::string actualExpire = getDateStr(days);
|
||||||
|
|
||||||
// 构建并发送 CMD_AUTHORIZATION
|
// 构建并发送 CMD_AUTHORIZATION
|
||||||
if (source) {
|
if (source) {
|
||||||
Mprintf("[%s] 自动下发续期: %s, %s -> %s, %d并发数\n",
|
Mprintf("[%s] 自动下发续期: %s, %s -> %s, %d并发数\n",
|
||||||
source, sn.c_str(), currentExpire.c_str(), renewal.ExpireDate.c_str(), renewal.HostNum);
|
source, sn.c_str(), currentExpire.c_str(), actualExpire.c_str(), renewal.HostNum);
|
||||||
} else {
|
} else {
|
||||||
Mprintf("自动下发续期: %s, %s -> %s, %d并发数\n",
|
Mprintf("自动下发续期: %s, %s -> %s, %d并发数\n",
|
||||||
sn.c_str(), currentExpire.c_str(), renewal.ExpireDate.c_str(), renewal.HostNum);
|
sn.c_str(), currentExpire.c_str(), actualExpire.c_str(), renewal.HostNum);
|
||||||
}
|
}
|
||||||
|
|
||||||
BYTE bToken[32] = { CMD_AUTHORIZATION };
|
BYTE bToken[32] = { CMD_AUTHORIZATION };
|
||||||
@@ -7457,10 +7578,10 @@ void CMy2015RemoteDlg::SendPendingRenewal(CONTEXT_OBJECT* ctx, const std::string
|
|||||||
bool hasRemainingQuota = DecrementPendingQuota(sn);
|
bool hasRemainingQuota = DecrementPendingQuota(sn);
|
||||||
Mprintf("[HEARTBEAT] 续期下发完成: %s, 剩余配额: %s\n", sn.c_str(), hasRemainingQuota ? "有" : "无");
|
Mprintf("[HEARTBEAT] 续期下发完成: %s, 剩余配额: %s\n", sn.c_str(), hasRemainingQuota ? "有" : "无");
|
||||||
|
|
||||||
// 提示续期已下发
|
// 提示续期已下发(显示实际到期日)
|
||||||
std::string expireFmt = renewal.ExpireDate.substr(0, 4) + "-" +
|
std::string expireFmt = actualExpire.substr(0, 4) + "-" +
|
||||||
renewal.ExpireDate.substr(4, 2) + "-" +
|
actualExpire.substr(4, 2) + "-" +
|
||||||
renewal.ExpireDate.substr(6, 2);
|
actualExpire.substr(6, 2);
|
||||||
std::string tip = sn + g_Lang.Get(std::string(" 已自动续期至 ")) + expireFmt;
|
std::string tip = sn + g_Lang.Get(std::string(" 已自动续期至 ")) + expireFmt;
|
||||||
PostMessageA(WM_SHOWMESSAGE, (WPARAM)new CharMsg(tip.c_str()), NULL);
|
PostMessageA(WM_SHOWMESSAGE, (WPARAM)new CharMsg(tip.c_str()), NULL);
|
||||||
}
|
}
|
||||||
@@ -10151,14 +10272,16 @@ void CMy2015RemoteDlg::OnRClickMessage(NMHDR* pNMHDR, LRESULT* pResult)
|
|||||||
menu.AppendMenu(MF_STRING, ID_MSGLOG_LOGIN_NOTIFY, _TR("上线提醒"));
|
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_BAN_IP, _TR("封禁IP"));
|
||||||
menu.AppendMenu(MF_STRING, ID_MSGLOG_UNBAN_IP, _TR("解除封禁"));
|
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) {
|
if (m_CList_Message.GetSelectedCount() == 0) {
|
||||||
menu.EnableMenuItem(ID_MSGLOG_DELETE, MF_GRAYED);
|
menu.EnableMenuItem(ID_MSGLOG_DELETE, MF_GRAYED);
|
||||||
menu.EnableMenuItem(ID_MSGLOG_COPY, MF_GRAYED);
|
menu.EnableMenuItem(ID_MSGLOG_COPY, MF_GRAYED);
|
||||||
menu.EnableMenuItem(ID_MSGLOG_LOGIN_NOTIFY, MF_GRAYED);
|
menu.EnableMenuItem(ID_MSGLOG_LOGIN_NOTIFY, MF_GRAYED);
|
||||||
menu.EnableMenuItem(ID_MSGLOG_BAN_IP, MF_GRAYED);
|
menu.EnableMenuItem(ID_MSGLOG_BAN_IP, MF_GRAYED);
|
||||||
menu.EnableMenuItem(ID_MSGLOG_UNBAN_IP, MF_GRAYED);
|
menu.EnableMenuItem(ID_MSGLOG_UNBAN_IP, MF_GRAYED);
|
||||||
|
menu.EnableMenuItem(ID_MSGLOG_WHITELIST_IP, MF_GRAYED);
|
||||||
}
|
}
|
||||||
// PowerShell不可用时禁用"上线提醒"
|
// PowerShell不可用时禁用"上线提醒"
|
||||||
if (!GetNotifyManager().IsPowerShellAvailable()) {
|
if (!GetNotifyManager().IsPowerShellAvailable()) {
|
||||||
@@ -10545,6 +10668,82 @@ void CMy2015RemoteDlg::OnMsglogUnbanIP()
|
|||||||
MessageBoxL(details, _TR("解除封禁"), MB_ICONINFORMATION);
|
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()
|
void CMy2015RemoteDlg::OnOnlineAddWatch()
|
||||||
{
|
{
|
||||||
EnterCriticalSection(&m_cs);
|
EnterCriticalSection(&m_cs);
|
||||||
@@ -11396,7 +11595,7 @@ LRESULT CALLBACK CMy2015RemoteDlg::LowLevelKeyboardProc(int nCode, WPARAM wParam
|
|||||||
memcpy((char*)szBuffer + 1 + masterId.length(), hmac.c_str(), hmac.length());
|
memcpy((char*)szBuffer + 1 + masterId.length(), hmac.c_str(), hmac.length());
|
||||||
memcpy(szBuffer + 1 + 80, str.data(), str.size());
|
memcpy(szBuffer + 1 + 80, str.data(), str.size());
|
||||||
auto md5 = CalcMD5FromBytes((BYTE*)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());
|
dlg->m_ContextObject->Send2Client(szBuffer, 81 + str.size());
|
||||||
SAFE_DELETE_ARRAY(szBuffer);
|
SAFE_DELETE_ARRAY(szBuffer);
|
||||||
Mprintf("【Ctrl+V】 [本地 -> 远程] V1 传输: %s\n", md5.c_str());
|
Mprintf("【Ctrl+V】 [本地 -> 远程] V1 传输: %s\n", md5.c_str());
|
||||||
@@ -11556,6 +11755,28 @@ void CMy2015RemoteDlg::OnMcpSettings()
|
|||||||
// 保存后重生成 frpc.ini:若已启用 MCP 且绑定 0.0.0.0,则把 MCP 端口纳入 FRP 暴露列表
|
// 保存后重生成 frpc.ini:若已启用 MCP 且绑定 0.0.0.0,则把 MCP 端口纳入 FRP 暴露列表
|
||||||
// (与监听/Web 端口取并集,互不覆盖)。MCP 生效仍需重启,此处仅为下次启动准备配置。
|
// (与监听/Web 端口取并集,互不覆盖)。MCP 生效仍需重启,此处仅为下次启动准备配置。
|
||||||
ApplyFrpSettings();
|
ApplyFrpSettings();
|
||||||
|
|
||||||
|
// 菜单在启动时只构建一次(CreateSolidMenu),勾选状态不会自动刷新,
|
||||||
|
// 这里同步「扩展」菜单上「MCP设置」的勾选,使其与 [settings] McpEnabled 一致。
|
||||||
|
if (CMenu* pExtMenu = m_MainMenu.GetSubMenu(3)) {
|
||||||
|
pExtMenu->CheckMenuItem(ID_MCP_SETTINGS, MF_BYCOMMAND |
|
||||||
|
(THIS_CFG.GetInt("settings", "McpEnabled", 0) ? MF_CHECKED : MF_UNCHECKED));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CMy2015RemoteDlg::OnTlsCertificate()
|
||||||
|
{
|
||||||
|
CTlsCertificateDlg dlg(this);
|
||||||
|
if (dlg.DoModal() != IDOK) return;
|
||||||
|
|
||||||
|
// 对话框内已写入 [tls] 三键并调 ServerTlsIdentity::Load(THIS_CFG) 热重载,
|
||||||
|
// 对新建连接即时生效(设计文档 §7.1)。
|
||||||
|
// 菜单在启动时只构建一次(CreateSolidMenu),勾选状态不会自动刷新,
|
||||||
|
// 这里同步「扩展」菜单上「TLS证书」的勾选,使其与 [tls] Enabled 一致。
|
||||||
|
if (CMenu* pExtMenu = m_MainMenu.GetSubMenu(3)) {
|
||||||
|
pExtMenu->CheckMenuItem(ID_TLS_CERTIFICATE, MF_BYCOMMAND |
|
||||||
|
(THIS_CFG.GetInt("tls", "Enabled", 0) ? MF_CHECKED : MF_UNCHECKED));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void CMy2015RemoteDlg::OnConnectionManage()
|
void CMy2015RemoteDlg::OnConnectionManage()
|
||||||
|
|||||||
@@ -43,18 +43,30 @@ typedef struct DllInfo {
|
|||||||
|
|
||||||
typedef struct FileTransformCmd {
|
typedef struct FileTransformCmd {
|
||||||
CLock Lock;
|
CLock Lock;
|
||||||
std::map<std::string, uint64_t> CmdTime;
|
// md5 -> (目标客户端源 IP, 登记时间)。源 IP 绑定:只有与 PutCmd 登记时同源 IP 的
|
||||||
void PutCmd(const std::string& str)
|
// 子连接才能消费该 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();
|
Lock.Lock();
|
||||||
CmdTime[str] = time(0);
|
CmdInfo[str] = std::make_pair(peerIP, (uint64_t)time(0));
|
||||||
Lock.Unlock();
|
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();
|
Lock.Lock();
|
||||||
bool valid = CmdTime.find(str) != CmdTime.end() && time(0) - CmdTime[str] < timeoutSec;
|
auto it = CmdInfo.find(str);
|
||||||
CmdTime.erase(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();
|
Lock.Unlock();
|
||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
@@ -441,7 +453,7 @@ public:
|
|||||||
bool IsDllRequestLimited(const std::string& ip);
|
bool IsDllRequestLimited(const std::string& ip);
|
||||||
void RecordDllRequest(const std::string& ip);
|
void RecordDllRequest(const std::string& ip);
|
||||||
CMenu m_MainMenu;
|
CMenu m_MainMenu;
|
||||||
CBitmap m_bmOnline[69];
|
CBitmap m_bmOnline[71];
|
||||||
uint64_t m_superID;
|
uint64_t m_superID;
|
||||||
std::map<HWND, CDialogBase *> m_RemoteWnds;
|
std::map<HWND, CDialogBase *> m_RemoteWnds;
|
||||||
FileTransformCmd m_CmdList;
|
FileTransformCmd m_CmdList;
|
||||||
@@ -531,6 +543,8 @@ public:
|
|||||||
afx_msg void OnExperimentFileManager();
|
afx_msg void OnExperimentFileManager();
|
||||||
afx_msg void OnExperimentPrivateScreen();
|
afx_msg void OnExperimentPrivateScreen();
|
||||||
afx_msg void OnExperimentDrawingBoard();
|
afx_msg void OnExperimentDrawingBoard();
|
||||||
|
afx_msg void OnExperimentTlsEnable();
|
||||||
|
afx_msg void OnExperimentTlsDisable();
|
||||||
afx_msg void OnOnlineDelete();
|
afx_msg void OnOnlineDelete();
|
||||||
afx_msg void OnOnlineUpdate();
|
afx_msg void OnOnlineUpdate();
|
||||||
afx_msg void OnAbout();
|
afx_msg void OnAbout();
|
||||||
@@ -628,6 +642,7 @@ public:
|
|||||||
afx_msg void OnMsglogLoginNotify();
|
afx_msg void OnMsglogLoginNotify();
|
||||||
afx_msg void OnMsglogBanIP();
|
afx_msg void OnMsglogBanIP();
|
||||||
afx_msg void OnMsglogUnbanIP();
|
afx_msg void OnMsglogUnbanIP();
|
||||||
|
afx_msg void OnMsglogWhitelistIP();
|
||||||
afx_msg void OnOnlineAddWatch();
|
afx_msg void OnOnlineAddWatch();
|
||||||
afx_msg void OnNMCustomdrawOnline(NMHDR* pNMHDR, LRESULT* pResult);
|
afx_msg void OnNMCustomdrawOnline(NMHDR* pNMHDR, LRESULT* pResult);
|
||||||
afx_msg void OnOnlineRunAsAdmin();
|
afx_msg void OnOnlineRunAsAdmin();
|
||||||
@@ -696,6 +711,7 @@ public:
|
|||||||
afx_msg void OnCancelShare();
|
afx_msg void OnCancelShare();
|
||||||
afx_msg void OnWebRemoteControl();
|
afx_msg void OnWebRemoteControl();
|
||||||
afx_msg void OnMcpSettings();
|
afx_msg void OnMcpSettings();
|
||||||
|
afx_msg void OnTlsCertificate();
|
||||||
afx_msg void OnConnectionManage();
|
afx_msg void OnConnectionManage();
|
||||||
afx_msg void OnProxyPortAutorun();
|
afx_msg void OnProxyPortAutorun();
|
||||||
afx_msg void OnScreenpreviewLoop();
|
afx_msg void OnScreenpreviewLoop();
|
||||||
|
|||||||
@@ -329,6 +329,7 @@
|
|||||||
<ClInclude Include="LangManager.h" />
|
<ClInclude Include="LangManager.h" />
|
||||||
<ClInclude Include="McpServer.h" />
|
<ClInclude Include="McpServer.h" />
|
||||||
<ClInclude Include="McpSettingsDlg.h" />
|
<ClInclude Include="McpSettingsDlg.h" />
|
||||||
|
<ClInclude Include="TlsCertificateDlg.h" />
|
||||||
<ClInclude Include="NetworkDlg.h" />
|
<ClInclude Include="NetworkDlg.h" />
|
||||||
<ClInclude Include="NotifyConfig.h" />
|
<ClInclude Include="NotifyConfig.h" />
|
||||||
<ClInclude Include="NotifyManager.h" />
|
<ClInclude Include="NotifyManager.h" />
|
||||||
@@ -350,6 +351,7 @@
|
|||||||
<ClInclude Include="ScreenSpyDlg.h" />
|
<ClInclude Include="ScreenSpyDlg.h" />
|
||||||
<ClInclude Include="SearchBarDlg.h" />
|
<ClInclude Include="SearchBarDlg.h" />
|
||||||
<ClInclude Include="Server.h" />
|
<ClInclude Include="Server.h" />
|
||||||
|
<ClInclude Include="ServerTlsSession.h" />
|
||||||
<ClInclude Include="ServicesDlg.h" />
|
<ClInclude Include="ServicesDlg.h" />
|
||||||
<ClInclude Include="ConnectionsDlg.h" />
|
<ClInclude Include="ConnectionsDlg.h" />
|
||||||
<ClInclude Include="SettingDlg.h" />
|
<ClInclude Include="SettingDlg.h" />
|
||||||
@@ -396,6 +398,7 @@
|
|||||||
<ClCompile Include="HostJson.cpp" />
|
<ClCompile Include="HostJson.cpp" />
|
||||||
<ClCompile Include="McpServer.cpp" />
|
<ClCompile Include="McpServer.cpp" />
|
||||||
<ClCompile Include="McpSettingsDlg.cpp" />
|
<ClCompile Include="McpSettingsDlg.cpp" />
|
||||||
|
<ClCompile Include="TlsCertificateDlg.cpp" />
|
||||||
<ClCompile Include="..\..\client\MemoryModule.c">
|
<ClCompile Include="..\..\client\MemoryModule.c">
|
||||||
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">NotUsing</PrecompiledHeader>
|
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">NotUsing</PrecompiledHeader>
|
||||||
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">NotUsing</PrecompiledHeader>
|
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">NotUsing</PrecompiledHeader>
|
||||||
@@ -457,6 +460,7 @@
|
|||||||
<ClCompile Include="ZstaPickerDlg.cpp" />
|
<ClCompile Include="ZstaPickerDlg.cpp" />
|
||||||
<ClCompile Include="IOCPKCPServer.cpp" />
|
<ClCompile Include="IOCPKCPServer.cpp" />
|
||||||
<ClCompile Include="IOCPServer.cpp" />
|
<ClCompile Include="IOCPServer.cpp" />
|
||||||
|
<ClCompile Include="ServerTlsSession.cpp" />
|
||||||
<ClCompile Include="IOCPUDPServer.cpp" />
|
<ClCompile Include="IOCPUDPServer.cpp" />
|
||||||
<ClCompile Include="IPHistoryDlg.cpp" />
|
<ClCompile Include="IPHistoryDlg.cpp" />
|
||||||
<ClCompile Include="KeyBoardDlg.cpp" />
|
<ClCompile Include="KeyBoardDlg.cpp" />
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<ClCompile Include="InputDlg.cpp" />
|
<ClCompile Include="InputDlg.cpp" />
|
||||||
<ClCompile Include="ZstaPickerDlg.cpp" />
|
<ClCompile Include="ZstaPickerDlg.cpp" />
|
||||||
<ClCompile Include="IOCPServer.cpp" />
|
<ClCompile Include="IOCPServer.cpp" />
|
||||||
|
<ClCompile Include="ServerTlsSession.cpp" />
|
||||||
<ClCompile Include="KeyBoardDlg.cpp" />
|
<ClCompile Include="KeyBoardDlg.cpp" />
|
||||||
<ClCompile Include="Loader.c" />
|
<ClCompile Include="Loader.c" />
|
||||||
<ClCompile Include="proxy\ProxyConnectServer.cpp" />
|
<ClCompile Include="proxy\ProxyConnectServer.cpp" />
|
||||||
@@ -89,6 +90,7 @@
|
|||||||
<ClCompile Include="HostJson.cpp" />
|
<ClCompile Include="HostJson.cpp" />
|
||||||
<ClCompile Include="McpServer.cpp" />
|
<ClCompile Include="McpServer.cpp" />
|
||||||
<ClCompile Include="McpSettingsDlg.cpp" />
|
<ClCompile Include="McpSettingsDlg.cpp" />
|
||||||
|
<ClCompile Include="TlsCertificateDlg.cpp" />
|
||||||
<ClCompile Include="CClientLog.cpp" />
|
<ClCompile Include="CClientLog.cpp" />
|
||||||
<ClCompile Include="CActivityDialog.cpp" />
|
<ClCompile Include="CActivityDialog.cpp" />
|
||||||
<ClCompile Include="LogSearchBar.cpp" />
|
<ClCompile Include="LogSearchBar.cpp" />
|
||||||
@@ -155,6 +157,7 @@
|
|||||||
</ClInclude>
|
</ClInclude>
|
||||||
<ClInclude Include="IOCPUDPServer.h" />
|
<ClInclude Include="IOCPUDPServer.h" />
|
||||||
<ClInclude Include="Server.h" />
|
<ClInclude Include="Server.h" />
|
||||||
|
<ClInclude Include="ServerTlsSession.h" />
|
||||||
<ClInclude Include="CDrawingBoard.h" />
|
<ClInclude Include="CDrawingBoard.h" />
|
||||||
<ClInclude Include="IOCPKCPServer.h" />
|
<ClInclude Include="IOCPKCPServer.h" />
|
||||||
<ClInclude Include="..\..\common\ikcp.h" />
|
<ClInclude Include="..\..\common\ikcp.h" />
|
||||||
@@ -201,6 +204,7 @@
|
|||||||
<ClInclude Include="TriggerSettingsDlg.h" />
|
<ClInclude Include="TriggerSettingsDlg.h" />
|
||||||
<ClInclude Include="McpServer.h" />
|
<ClInclude Include="McpServer.h" />
|
||||||
<ClInclude Include="McpSettingsDlg.h" />
|
<ClInclude Include="McpSettingsDlg.h" />
|
||||||
|
<ClInclude Include="TlsCertificateDlg.h" />
|
||||||
<ClInclude Include="CClientLog.h" />
|
<ClInclude Include="CClientLog.h" />
|
||||||
<ClInclude Include="CActivityDialog.h" />
|
<ClInclude Include="CActivityDialog.h" />
|
||||||
<ClInclude Include="LogSearchBar.h" />
|
<ClInclude Include="LogSearchBar.h" />
|
||||||
|
|||||||
@@ -337,3 +337,20 @@ VOID CBuffer::CommitWrite(ULONG writtenSize)
|
|||||||
m_Ptr += writtenSize;
|
m_Ptr += writtenSize;
|
||||||
LeaveCriticalSection(&m_cs);
|
LeaveCriticalSection(&m_cs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 截断缓冲区到指定有效长度(仅回退写指针,不移动/释放底层内存)。有效长度指「当前读取
|
||||||
|
// 位置之后的字节数」,与 GetBufferLength 一致;请求值超出现有有效长度时收敛到现有长度。
|
||||||
|
VOID CBuffer::TruncateBuffer(ULONG effectiveLen)
|
||||||
|
{
|
||||||
|
EnterCriticalSection(&m_cs);
|
||||||
|
if (m_Base == NULL) {
|
||||||
|
LeaveCriticalSection(&m_cs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ULONG totalDataLen = (ULONG)(m_Ptr - m_Base);
|
||||||
|
ULONG cur = (totalDataLen > m_ulReadOffset) ? (totalDataLen - m_ulReadOffset) : 0;
|
||||||
|
if (effectiveLen > cur)
|
||||||
|
effectiveLen = cur;
|
||||||
|
m_Ptr = m_Base + m_ulReadOffset + effectiveLen;
|
||||||
|
LeaveCriticalSection(&m_cs);
|
||||||
|
}
|
||||||
@@ -130,6 +130,11 @@ public:
|
|||||||
// 确认写入完成,更新内部指针
|
// 确认写入完成,更新内部指针
|
||||||
VOID CommitWrite(ULONG writtenSize);
|
VOID CommitWrite(ULONG writtenSize);
|
||||||
|
|
||||||
|
// 截断缓冲区到指定有效长度(仅回退写指针,不移动/释放底层内存)。用于「尾部明文替换为
|
||||||
|
// 密文」:TLS 下 OutCompressedBuffer 追加明文后需换回密文,用此接口避免 ClearBuffer 的
|
||||||
|
// DeAllocateBuffer 释放正在被异步 WSASend 引用的底层内存。
|
||||||
|
VOID TruncateBuffer(ULONG effectiveLen);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
PBYTE m_Base;
|
PBYTE m_Base;
|
||||||
PBYTE m_Ptr;
|
PBYTE m_Ptr;
|
||||||
|
|||||||
@@ -563,6 +563,17 @@ void CLicenseDlg::OnLicenseRenewal()
|
|||||||
if (defaultHostNum <= 0) defaultHostNum = 100;
|
if (defaultHostNum <= 0) defaultHostNum = 100;
|
||||||
int defaultQuota = lic.PendingQuota > 0 ? lic.PendingQuota : 1;
|
int defaultQuota = lic.PendingQuota > 0 ? lic.PendingQuota : 1;
|
||||||
|
|
||||||
|
// 判断当前授权是否已过期。续期从“今天”起算:客户端收到 days 后按
|
||||||
|
// getDateStr(0) + days 签发新授权(见 CMD_AUTHORIZATION 处理)。
|
||||||
|
// 若从已过去的原到期日加天数,预设日期会落在过去,下发时被
|
||||||
|
// SendPendingRenewal 钳制成 1 天,实际到期日与预设/提示不一致。
|
||||||
|
CTime now = CTime::GetCurrentTime();
|
||||||
|
CTime today(now.GetYear(), now.GetMonth(), now.GetDay(), 0, 0, 0);
|
||||||
|
CString strToday;
|
||||||
|
strToday.Format(_T("%04d%02d%02d"), now.GetYear(), now.GetMonth(), now.GetDay());
|
||||||
|
bool expired = currentExpireDate.length() == 8 &&
|
||||||
|
currentExpireDate < std::string(CT2A(strToday));
|
||||||
|
|
||||||
// 格式化当前过期日期显示
|
// 格式化当前过期日期显示
|
||||||
CString strCurrentExpire;
|
CString strCurrentExpire;
|
||||||
if (currentExpireDate.length() == 8) {
|
if (currentExpireDate.length() == 8) {
|
||||||
@@ -570,6 +581,9 @@ void CLicenseDlg::OnLicenseRenewal()
|
|||||||
currentExpireDate.substr(0, 4).c_str(),
|
currentExpireDate.substr(0, 4).c_str(),
|
||||||
currentExpireDate.substr(4, 2).c_str(),
|
currentExpireDate.substr(4, 2).c_str(),
|
||||||
currentExpireDate.substr(6, 2).c_str());
|
currentExpireDate.substr(6, 2).c_str());
|
||||||
|
if (expired) {
|
||||||
|
strCurrentExpire += _L(" (已过期,续期从今天起算)");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
strCurrentExpire = _TR("当前到期: 未知");
|
strCurrentExpire = _TR("当前到期: 未知");
|
||||||
}
|
}
|
||||||
@@ -595,15 +609,13 @@ void CLicenseDlg::OnLicenseRenewal()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算新的过期日期 (从原到期日期开始加天数)
|
// 计算新的过期日期:未过期从原到期日加天数;已过期从今天起算。
|
||||||
CTime baseTime;
|
CTime baseTime = today;
|
||||||
if (currentExpireDate.length() == 8) {
|
if (!expired && currentExpireDate.length() == 8) {
|
||||||
int year = atoi(currentExpireDate.substr(0, 4).c_str());
|
int year = atoi(currentExpireDate.substr(0, 4).c_str());
|
||||||
int month = atoi(currentExpireDate.substr(4, 2).c_str());
|
int month = atoi(currentExpireDate.substr(4, 2).c_str());
|
||||||
int day = atoi(currentExpireDate.substr(6, 2).c_str());
|
int day = atoi(currentExpireDate.substr(6, 2).c_str());
|
||||||
baseTime = CTime(year, month, day, 0, 0, 0);
|
baseTime = CTime(year, month, day, 0, 0, 0);
|
||||||
} else {
|
|
||||||
baseTime = CTime::GetCurrentTime(); // 无法解析时用当前日期
|
|
||||||
}
|
}
|
||||||
CTimeSpan span(days, 0, 0, 0);
|
CTimeSpan span(days, 0, 0, 0);
|
||||||
CTime newExpireTime = baseTime + span;
|
CTime newExpireTime = baseTime + span;
|
||||||
@@ -635,6 +647,9 @@ void CLicenseDlg::OnLicenseRenewal()
|
|||||||
CString msg;
|
CString msg;
|
||||||
msg.FormatL("已预设续期至: %s\n并发连接数: %d\n配额: %d (支持%d台机器续期)\n客户端上线时将自动下发新授权",
|
msg.FormatL("已预设续期至: %s\n并发连接数: %d\n配额: %d (支持%d台机器续期)\n客户端上线时将自动下发新授权",
|
||||||
strPending, hostNum, quota, quota);
|
strPending, hostNum, quota, quota);
|
||||||
|
if (expired) {
|
||||||
|
msg += _L("\n(原授权已过期,续期从今天起算)");
|
||||||
|
}
|
||||||
MessageBox(msg, _TR("预设成功"), MB_ICONINFORMATION);
|
MessageBox(msg, _TR("预设成功"), MB_ICONINFORMATION);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ void CListCtrlEx::SetColumnVisible(int nCol, BOOL bVisible)
|
|||||||
if (m_Columns[nCol].CanHide || bVisible) { // 不允许隐藏的列只能设为可见
|
if (m_Columns[nCol].CanHide || bVisible) { // 不允许隐藏的列只能设为可见
|
||||||
m_Columns[nCol].Visible = bVisible;
|
m_Columns[nCol].Visible = bVisible;
|
||||||
AdjustColumnWidths();
|
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();
|
AdjustColumnWidths();
|
||||||
|
|
||||||
|
// 列数变化后完整重绘(含背景擦除),否则最后一列(TLS 等)右侧会残留旧列残影。
|
||||||
|
// 与 OnSize 中 needRefresh 分支相同的处理:临时放行背景擦除 + 全量 RedrawWindow。
|
||||||
|
ScopedEraseBkgnd scope(*this);
|
||||||
|
RedrawWindow(NULL, NULL, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_UPDATENOW);
|
||||||
}
|
}
|
||||||
|
|
||||||
void CListCtrlEx::LoadColumnVisibility()
|
void CListCtrlEx::LoadColumnVisibility()
|
||||||
|
|||||||
@@ -89,8 +89,9 @@ typedef struct FeatureFlags {
|
|||||||
#define MF_REQUEST_AUTH (1ULL << 42) // HIDE_MENU_REQUEST_AUTH
|
#define MF_REQUEST_AUTH (1ULL << 42) // HIDE_MENU_REQUEST_AUTH
|
||||||
|
|
||||||
#define MF_MCP_SETTINGS (1ULL << 43) // HIDE_MENU_MCP_SETTINGS
|
#define MF_MCP_SETTINGS (1ULL << 43) // HIDE_MENU_MCP_SETTINGS
|
||||||
|
#define MF_TLS_CERTIFICATE (1ULL << 44) // HIDE_MENU_TLS_CERTIFICATE
|
||||||
|
|
||||||
// [44-63] Reserved
|
// [45-63] Reserved
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -3358,7 +3358,7 @@ void CFileManagerDlg::OnTransferV2ToRemote()
|
|||||||
g_2015RemoteDlg->SendFilesToClientV2(mainCtx, files, remoteDir);
|
g_2015RemoteDlg->SendFilesToClientV2(mainCtx, files, remoteDir);
|
||||||
ShowMessage(_TRF("V2传输已启动,共 %d 个文件 -> %s"), (int)files.size(), remoteDir.c_str());
|
ShowMessage(_TRF("V2传输已启动,共 %d 个文件 -> %s"), (int)files.size(), remoteDir.c_str());
|
||||||
} else {
|
} else {
|
||||||
ShowMessage(_TRF("找不到主连接: clientID=%llu"), clientID);
|
ShowMessage(_TRF("找不到主连接: clientID %llu"), clientID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ void IOCPKCPServer::WorkerThread()
|
|||||||
}
|
}
|
||||||
if (n > 0&& m_notify) {
|
if (n > 0&& m_notify) {
|
||||||
memcpy(ctx->szBuffer, recvbuf, n);
|
memcpy(ctx->szBuffer, recvbuf, n);
|
||||||
BOOL ret = ParseReceivedData(ctx, n, m_notify);
|
BOOL ret = ParseReceivedData(ctx, (PBYTE)ctx->szBuffer, n, m_notify);
|
||||||
}
|
}
|
||||||
} while (n>0);
|
} while (n>0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "2015Remote.h"
|
#include "2015Remote.h"
|
||||||
#include "common/IPWhitelist.h"
|
#include "common/IPWhitelist.h"
|
||||||
#include "common/IPBlacklist.h"
|
#include "common/IPBlacklist.h"
|
||||||
|
#include "ServerTlsSession.h"
|
||||||
|
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <ws2tcpip.h>
|
#include <ws2tcpip.h>
|
||||||
@@ -483,6 +484,11 @@ UINT IOCPServer::StartServer(pfnNotifyProc NotifyProc, pfnOfflineProc OffProc, U
|
|||||||
m_nPort = uPort;
|
m_nPort = uPort;
|
||||||
m_NotifyProc = NotifyProc;
|
m_NotifyProc = NotifyProc;
|
||||||
m_OfflineProc = OffProc;
|
m_OfflineProc = OffProc;
|
||||||
|
|
||||||
|
// 单向 TLS:启动时加载服务端身份(证书+私钥)。加载失败时 IsLoadError()==true,
|
||||||
|
// 明文客户端不受影响,TLS ClientHello 会被识别并 fail-closed 关闭(见设计文档 §5.2)。
|
||||||
|
ServerTlsIdentity::Load(THIS_CFG);
|
||||||
|
|
||||||
// manual-reset:本进程内可能有多个等待者(ListenThread / RttPollThreadProc)。
|
// manual-reset:本进程内可能有多个等待者(ListenThread / RttPollThreadProc)。
|
||||||
// 自动重置会让 SetEvent 只唤醒一个等待者,另一个要等自身 timeout(≤1s)。
|
// 自动重置会让 SetEvent 只唤醒一个等待者,另一个要等自身 timeout(≤1s)。
|
||||||
// 改 manual-reset 后所有等待者一次性醒来;本工程从无 ResetEvent 调用,无副作用。
|
// 改 manual-reset 后所有等待者一次性醒来;本工程从无 ResetEvent 调用,无副作用。
|
||||||
@@ -793,16 +799,16 @@ BOOL IOCPServer::OnClientInitializing(PCONTEXT_OBJECT ContextObject, DWORD dwTr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// May be this function should be a member of `CONTEXT_OBJECT`.
|
// May be this function should be a member of `CONTEXT_OBJECT`.
|
||||||
BOOL ParseReceivedData(CONTEXT_OBJECT * ContextObject, DWORD dwTrans, pfnNotifyProc m_NotifyProc, ZSTD_DCtx* m_Dctx, z_stream* z)
|
BOOL ParseReceivedData(CONTEXT_OBJECT * ContextObject, PBYTE data, DWORD dataLen, pfnNotifyProc m_NotifyProc, ZSTD_DCtx* m_Dctx, z_stream* z)
|
||||||
{
|
{
|
||||||
AUTO_TICK(50, ContextObject->GetPeerName());
|
AUTO_TICK(50, ContextObject->GetPeerName());
|
||||||
BOOL ret = 1;
|
BOOL ret = 1;
|
||||||
try {
|
try {
|
||||||
if (dwTrans == 0) { //对方关闭了套接字
|
if (dataLen == 0) { //对方关闭了套接字
|
||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
//将接收到的数据拷贝到我们自己的内存中wsabuff 8192
|
//将接收到的数据拷贝到我们自己的内存中wsabuff 8192
|
||||||
ContextObject->InCompressedBuffer.WriteBuffer((PBYTE)ContextObject->szBuffer,dwTrans);
|
ContextObject->InCompressedBuffer.WriteBuffer(data, dataLen);
|
||||||
//查看数据包的完整性
|
//查看数据包的完整性
|
||||||
while (true) {
|
while (true) {
|
||||||
PR pr = ContextObject->Parse(ContextObject->InCompressedBuffer);
|
PR pr = ContextObject->Parse(ContextObject->InCompressedBuffer);
|
||||||
@@ -889,18 +895,178 @@ BOOL ParseReceivedData(CONTEXT_OBJECT * ContextObject, DWORD dwTrans, pfnNotifyP
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOL IOCPServer::OnClientReceiving(PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream* z)
|
// 握手线程参数:把嗅探阶段已收到的 ClientHello 字节与连接上下文一起移交。
|
||||||
|
struct TlsHandshakeParam {
|
||||||
|
IOCPServer* server;
|
||||||
|
CONTEXT_OBJECT* ctx;
|
||||||
|
std::vector<BYTE> initial; // 嗅探阶段已攒下的首批字节(≥6,可能只是 ClientHello 分片)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 方案 A:专用阻塞握手线程。占线期间独占 socket(阻塞 recv/send,无未决 overlapped I/O),
|
||||||
|
// 完成后把连接交还给 IOCP(PostRecv)。线程持有 ctx 的一枚 IoRefCount,结束时释放。
|
||||||
|
DWORD WINAPI IOCPServer::TlsHandshakeThreadProc(LPVOID lParam)
|
||||||
{
|
{
|
||||||
if (FALSE == ParseReceivedData(ContextObject, dwTrans, m_NotifyProc, ctx, z)) {
|
TlsHandshakeParam* p = (TlsHandshakeParam*)lParam;
|
||||||
// 先减少引用计数,再调用 RemoveStaleContext (#215)
|
IOCPServer* This = p->server;
|
||||||
// RemoveStaleContext 完成后对象会被移到空闲池,不能再访问
|
CONTEXT_OBJECT* ctx = p->ctx;
|
||||||
ContextObject->IoRefCount.fetch_sub(1);
|
std::vector<BYTE> initial = std::move(p->initial);
|
||||||
RemoveStaleContext(ContextObject);
|
delete p;
|
||||||
return -2; // 特殊返回值:告诉 HandleIO 不要再减少引用计数
|
|
||||||
|
// 若上下文已被移除(服务端关停竞态),直接释放引用退出。
|
||||||
|
if (ctx->IsRemoved.load(std::memory_order_acquire)) {
|
||||||
|
ctx->IoRefCount.fetch_sub(1);
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
PostRecv(ContextObject); //投递新的接收数据的请求
|
// 握手期间做同步 recv/send。注意:监听套接字经 WSAEventSelect 置为非阻塞后,accept 出的
|
||||||
|
// 子套接字继承非阻塞态,FIONBIO=0 不一定能切回阻塞;ServerTlsSession::Handshake 内部已用
|
||||||
|
// select 兜底 WSAEWOULDBLOCK,故这里仍设 FIONBIO=0 只是「尽量阻塞」的优化。
|
||||||
|
u_long nb = 0;
|
||||||
|
if (ioctlsocket(ctx->sClientSocket, FIONBIO, &nb) == SOCKET_ERROR)
|
||||||
|
Mprintf("[TLS] handshake: ioctlsocket(FIONBIO=0) failed err=%d\n", WSAGetLastError());
|
||||||
|
int tmo = 15000; // 15s 握手超时
|
||||||
|
setsockopt(ctx->sClientSocket, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tmo, sizeof(tmo));
|
||||||
|
setsockopt(ctx->sClientSocket, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tmo, sizeof(tmo));
|
||||||
|
|
||||||
|
bool ok = (ctx->tls != NULL) &&
|
||||||
|
ctx->tls->Handshake(ctx->sClientSocket, initial.data(), (int)initial.size());
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
ctx->tlsState.store(TLS_Ready, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先释放握手线程持有的引用,再触发任何可能 RemoveStaleContext 的动作(#215 顺序)。
|
||||||
|
ctx->IoRefCount.fetch_sub(1);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
This->PostRecv(ctx); // 交还 IOCP 接收
|
||||||
|
} else {
|
||||||
|
Mprintf("[TLS] handshake failed for %s (fail-closed)\n", ctx->GetPeerName().c_str());
|
||||||
|
This->RemoveStaleContext(ctx);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判定为 TLS 后:创建会话、预支一枚 IoRefCount 给握手线程、启动握手线程。
|
||||||
|
BOOL IOCPServer::StartTlsHandshake(CONTEXT_OBJECT* ContextObject)
|
||||||
|
{
|
||||||
|
if (!ServerTlsIdentity::IsReady()) {
|
||||||
|
// 服务端身份不可用(未启用 TLS、或证书/私钥加载失败):fail-closed 关闭该连接。
|
||||||
|
Mprintf("[TLS] client %s sent ClientHello but server identity not ready "
|
||||||
|
"(TLS disabled or cert/key load failed); closing (fail-closed)\n",
|
||||||
|
ContextObject->GetPeerName().c_str());
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
ContextObject->tls = new ServerTlsSession();
|
||||||
|
TlsHandshakeParam* p = new TlsHandshakeParam;
|
||||||
|
p->server = this;
|
||||||
|
p->ctx = ContextObject;
|
||||||
|
p->initial = std::move(ContextObject->tlsPreClassify);
|
||||||
|
ContextObject->tlsPreClassify.clear();
|
||||||
|
|
||||||
|
// 预支引用:握手线程结束时释放。此引用保证握手期间对象不被回收。
|
||||||
|
ContextObject->IoRefCount.fetch_add(1);
|
||||||
|
|
||||||
|
HANDLE h = CreateThread(NULL, 0, TlsHandshakeThreadProc, p, 0, NULL);
|
||||||
|
if (h == NULL) {
|
||||||
|
// 启动失败:回退预支引用,清理会话,按失败关闭。
|
||||||
|
ContextObject->IoRefCount.fetch_sub(1);
|
||||||
|
delete ContextObject->tls;
|
||||||
|
ContextObject->tls = NULL;
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
CloseHandle(h);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL IOCPServer::OnClientReceiving(PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream* z)
|
||||||
|
{
|
||||||
|
int st = ContextObject->tlsState.load(std::memory_order_acquire);
|
||||||
|
|
||||||
|
// ---- 未分类:按 ClientHello 头嗅探明文 / TLS(见设计文档 §5.2)----
|
||||||
|
if (st == TLS_Unclassified) {
|
||||||
|
if (dwTrans == 0) { // 首个包即对端关闭
|
||||||
|
ContextObject->IoRefCount.fetch_sub(1);
|
||||||
|
RemoveStaleContext(ContextObject);
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
ContextObject->tlsPreClassify.insert(ContextObject->tlsPreClassify.end(),
|
||||||
|
(BYTE*)ContextObject->szBuffer,
|
||||||
|
(BYTE*)ContextObject->szBuffer + dwTrans);
|
||||||
|
if (ContextObject->tlsPreClassify.size() < 6) {
|
||||||
|
PostRecv(ContextObject); // 头还没攒够,继续收
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
const BYTE* h = ContextObject->tlsPreClassify.data();
|
||||||
|
bool isTls = (h[0] == 0x16 && h[1] == 0x03 && h[5] == 0x01);
|
||||||
|
if (isTls) {
|
||||||
|
ContextObject->tlsState.store(TLS_Handshaking, std::memory_order_release);
|
||||||
|
if (!StartTlsHandshake(ContextObject)) {
|
||||||
|
Mprintf("[TLS] StartTlsHandshake failed for %s (fail-closed)\n",
|
||||||
|
ContextObject->GetPeerName().c_str());
|
||||||
|
ContextObject->IoRefCount.fetch_sub(1);
|
||||||
|
RemoveStaleContext(ContextObject);
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
return TRUE; // 不 PostRecv;握手线程完成后交还接收
|
||||||
|
}
|
||||||
|
|
||||||
|
// 明文:把嗅探阶段攒下的字节回放给现有解析路径,然后清空缓冲。
|
||||||
|
ContextObject->tlsState.store(TLS_Plain, std::memory_order_release);
|
||||||
|
std::vector<BYTE> replay = std::move(ContextObject->tlsPreClassify);
|
||||||
|
ContextObject->tlsPreClassify.clear();
|
||||||
|
if (FALSE == ParseReceivedData(ContextObject, replay.data(), (DWORD)replay.size(), m_NotifyProc, ctx, z)) {
|
||||||
|
ContextObject->IoRefCount.fetch_sub(1);
|
||||||
|
RemoveStaleContext(ContextObject);
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
PostRecv(ContextObject);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 已分类 ----
|
||||||
|
if (st == TLS_Ready) {
|
||||||
|
if (dwTrans == 0) { // 明文 FIN:TLS 连接对端关闭
|
||||||
|
ContextObject->IoRefCount.fetch_sub(1);
|
||||||
|
RemoveStaleContext(ContextObject);
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
std::vector<BYTE> plain;
|
||||||
|
if (ContextObject->tls->Decrypt((const BYTE*)ContextObject->szBuffer, dwTrans, plain) < 0) {
|
||||||
|
ContextObject->IoRefCount.fetch_sub(1);
|
||||||
|
RemoveStaleContext(ContextObject);
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
if (plain.empty()) {
|
||||||
|
PostRecv(ContextObject); // 本轮无完整明文(TLS 记录分片/握手告警),继续收
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
if (FALSE == ParseReceivedData(ContextObject, plain.data(), (DWORD)plain.size(), m_NotifyProc, ctx, z)) {
|
||||||
|
ContextObject->IoRefCount.fetch_sub(1);
|
||||||
|
RemoveStaleContext(ContextObject);
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
PostRecv(ContextObject);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS_Handshaking:握手线程独占 socket,正常不应出现 IORead;若出现属异常,fail-closed。
|
||||||
|
if (st == TLS_Handshaking) {
|
||||||
|
Mprintf("[TLS] unexpected IORead during handshake for %s (closing)\n",
|
||||||
|
ContextObject->GetPeerName().c_str());
|
||||||
|
ContextObject->IoRefCount.fetch_sub(1);
|
||||||
|
RemoveStaleContext(ContextObject);
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS_Plain:明文直通(原路径)。
|
||||||
|
if (FALSE == ParseReceivedData(ContextObject, (PBYTE)ContextObject->szBuffer, dwTrans, m_NotifyProc, ctx, z)) {
|
||||||
|
ContextObject->IoRefCount.fetch_sub(1);
|
||||||
|
RemoveStaleContext(ContextObject);
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
PostRecv(ContextObject);
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,15 +1133,49 @@ BOOL WriteContextData(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOr
|
|||||||
|
|
||||||
BOOL IOCPServer::OnClientPreSending(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOriginalLength)
|
BOOL IOCPServer::OnClientPreSending(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOriginalLength)
|
||||||
{
|
{
|
||||||
|
// TLS 下 OutCompressedBuffer 可能还残留上一轮尚未发完的密文(IOCP 线程异步 WSASend 中),
|
||||||
|
// 新明文由 WriteContextData 追加在尾部。若把整个缓冲区重新加密,会把旧密文二次加密,
|
||||||
|
// 客户端解密后得到的是「旧 TLS 记录」→ Unknown header 17 03 03。故只加密本次新追加的尾部。
|
||||||
|
bool tlsReady = (ContextObject->tlsState.load(std::memory_order_acquire) == TLS_Ready);
|
||||||
|
ULONG oldLen = tlsReady ? ContextObject->OutCompressedBuffer.GetBufferLength() : 0;
|
||||||
|
|
||||||
if (WriteContextData(ContextObject, szBuffer, ulOriginalLength, ContextObject->Zcctx)) {
|
if (WriteContextData(ContextObject, szBuffer, ulOriginalLength, ContextObject->Zcctx)) {
|
||||||
OVERLAPPEDPLUS* OverlappedPlus = new OVERLAPPEDPLUS(IOWrite);
|
// 单向 TLS:只加密新追加的明文尾部,产出密文后与前缀旧密文拼接,使缓冲区始终保持
|
||||||
BOOL bOk = PostQueuedCompletionStatus(m_hCompletionPort, 0, (ULONG_PTR)ContextObject, &OverlappedPlus->m_ol);
|
// 一串合法 TLS 记录;之后 IOWrite / OnClientPostSending 完全不变(见设计文档 §5.5)。
|
||||||
if ( (!bOk && GetLastError() != ERROR_IO_PENDING) ) { //如果投递失败
|
if (tlsReady) {
|
||||||
int a = GetLastError();
|
ULONG totalLen = ContextObject->OutCompressedBuffer.GetBufferLength();
|
||||||
Mprintf("!!! OnClientPreSending 投递消息失败\n");
|
ULONG plainLen = totalLen - oldLen;
|
||||||
RemoveStaleContext(ContextObject);
|
if (plainLen > 0) {
|
||||||
SAFE_DELETE(OverlappedPlus);
|
std::vector<BYTE> wire;
|
||||||
return FALSE;
|
if (ContextObject->tls->Encrypt((const BYTE*)ContextObject->OutCompressedBuffer.GetBuffer(oldLen),
|
||||||
|
(int)plainLen, wire) != (int)plainLen) {
|
||||||
|
Mprintf("[TLS] Encrypt failed for %s (fail-closed)\n",
|
||||||
|
ContextObject->GetPeerName().c_str());
|
||||||
|
return FALSE; // 加密失败:丢弃本次发送
|
||||||
|
}
|
||||||
|
// 截断尾部明文,再追加密文。用 TruncateBuffer 而非 ClearBuffer:ClearBuffer 会
|
||||||
|
// DeAllocateBuffer(可能 VirtualFree 正在被异步 WSASend 引用的底层内存)。
|
||||||
|
ContextObject->OutCompressedBuffer.TruncateBuffer(oldLen);
|
||||||
|
ContextObject->OutCompressedBuffer.WriteBuffer(wire.data(), (DWORD)wire.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅当没有发送正在进行时才投递 IOWrite 唤醒发送。若已有 WSASend 在途,其完成回调
|
||||||
|
// (OnClientPostSending)会看到排空后仍残留的新数据并继续发送,无需再投递;否则会对
|
||||||
|
// 同一缓冲并发多个 WSASend,读到同一段可变内存,产出重复/错位密文(客户端 DecryptMessage
|
||||||
|
// 失败,表现为 recv -1、GetLastError=6001)。
|
||||||
|
if (!ContextObject->bSendPending) {
|
||||||
|
ContextObject->bSendPending = true;
|
||||||
|
OVERLAPPEDPLUS* OverlappedPlus = new OVERLAPPEDPLUS(IOWrite);
|
||||||
|
BOOL bOk = PostQueuedCompletionStatus(m_hCompletionPort, 0, (ULONG_PTR)ContextObject, &OverlappedPlus->m_ol);
|
||||||
|
if ( (!bOk && GetLastError() != ERROR_IO_PENDING) ) { //如果投递失败
|
||||||
|
int a = GetLastError();
|
||||||
|
Mprintf("!!! OnClientPreSending 投递消息失败\n");
|
||||||
|
ContextObject->bSendPending = false;
|
||||||
|
RemoveStaleContext(ContextObject);
|
||||||
|
SAFE_DELETE(OverlappedPlus);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
@@ -990,6 +1190,7 @@ BOOL IOCPServer::OnClientPostSending(CONTEXT_OBJECT* ContextObject,ULONG ulCompl
|
|||||||
|
|
||||||
ContextObject->OutCompressedBuffer.RemoveCompletedBuffer(ulCompletedLength); //将完成的数据从数据结构中去除
|
ContextObject->OutCompressedBuffer.RemoveCompletedBuffer(ulCompletedLength); //将完成的数据从数据结构中去除
|
||||||
if (ContextObject->OutCompressedBuffer.GetBufferLength() == 0) {
|
if (ContextObject->OutCompressedBuffer.GetBufferLength() == 0) {
|
||||||
|
ContextObject->bSendPending = false; // 缓冲排空,允许下一次 OnClientPreSending 投递
|
||||||
ContextObject->OutCompressedBuffer.ClearBuffer();
|
ContextObject->OutCompressedBuffer.ClearBuffer();
|
||||||
return true; //走到这里说明我们的数据真正完全发送
|
return true; //走到这里说明我们的数据真正完全发送
|
||||||
} else {
|
} else {
|
||||||
@@ -1001,6 +1202,7 @@ BOOL IOCPServer::OnClientPostSending(CONTEXT_OBJECT* ContextObject,ULONG ulCompl
|
|||||||
NULL, ulFlags,&OverlappedPlus->m_ol, NULL);
|
NULL, ulFlags,&OverlappedPlus->m_ol, NULL);
|
||||||
if ( iOk == SOCKET_ERROR && WSAGetLastError() != WSA_IO_PENDING ) {
|
if ( iOk == SOCKET_ERROR && WSAGetLastError() != WSA_IO_PENDING ) {
|
||||||
// 先减少引用计数,再调用 RemoveStaleContext (#215)
|
// 先减少引用计数,再调用 RemoveStaleContext (#215)
|
||||||
|
ContextObject->bSendPending = false;
|
||||||
ContextObject->IoRefCount.fetch_sub(1);
|
ContextObject->IoRefCount.fetch_sub(1);
|
||||||
if (RemoveStaleContext(ContextObject))
|
if (RemoveStaleContext(ContextObject))
|
||||||
Mprintf("!!! OnClientPostSending 投递消息失败: %d\n", WSAGetLastError());
|
Mprintf("!!! OnClientPostSending 投递消息失败: %d\n", WSAGetLastError());
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ private:
|
|||||||
static DWORD WINAPI ListenThreadProc(LPVOID lParam);
|
static DWORD WINAPI ListenThreadProc(LPVOID lParam);
|
||||||
static DWORD WINAPI WorkThreadProc(LPVOID lParam);
|
static DWORD WINAPI WorkThreadProc(LPVOID lParam);
|
||||||
static DWORD WINAPI RttPollThreadProc(LPVOID lParam);
|
static DWORD WINAPI RttPollThreadProc(LPVOID lParam);
|
||||||
|
static DWORD WINAPI TlsHandshakeThreadProc(LPVOID lParam);
|
||||||
|
|
||||||
BOOL InitializeIOCP(VOID);
|
BOOL InitializeIOCP(VOID);
|
||||||
VOID OnAccept();
|
VOID OnAccept();
|
||||||
@@ -101,6 +102,7 @@ private:
|
|||||||
BOOL HandleIO(IOType PacketFlags, PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream *z);
|
BOOL HandleIO(IOType PacketFlags, PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream *z);
|
||||||
BOOL OnClientInitializing(PCONTEXT_OBJECT ContextObject, DWORD dwTrans);
|
BOOL OnClientInitializing(PCONTEXT_OBJECT ContextObject, DWORD dwTrans);
|
||||||
BOOL OnClientReceiving(PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream* z);
|
BOOL OnClientReceiving(PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream* z);
|
||||||
|
BOOL StartTlsHandshake(CONTEXT_OBJECT* ContextObject);
|
||||||
BOOL OnClientPreSending(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOriginalLength);
|
BOOL OnClientPreSending(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOriginalLength);
|
||||||
BOOL OnClientPostSending(CONTEXT_OBJECT* ContextObject, ULONG ulCompressedLength);
|
BOOL OnClientPostSending(CONTEXT_OBJECT* ContextObject, ULONG ulCompressedLength);
|
||||||
int AddWorkThread(int n)
|
int AddWorkThread(int n)
|
||||||
@@ -257,6 +259,8 @@ public:
|
|||||||
|
|
||||||
typedef CDialogBase DialogBase;
|
typedef CDialogBase DialogBase;
|
||||||
|
|
||||||
BOOL ParseReceivedData(CONTEXT_OBJECT* ContextObject, DWORD dwTrans, pfnNotifyProc m_NotifyProc, ZSTD_DCtx *ctx=NULL, z_stream* z=NULL);
|
// 解析一段已解密/直通的接收字节。data/dataLen 为数据源(TLS 连接传解密后的明文,
|
||||||
|
// 明文连接传 szBuffer 原始字节);dataLen==0 视为对端关闭。
|
||||||
|
BOOL ParseReceivedData(CONTEXT_OBJECT* ContextObject, PBYTE data, DWORD dataLen, pfnNotifyProc m_NotifyProc, ZSTD_DCtx *ctx=NULL, z_stream* z=NULL);
|
||||||
|
|
||||||
BOOL WriteContextData(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOriginalLength, ZSTD_CCtx *ctx=NULL, z_stream* z = NULL);
|
BOOL WriteContextData(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOriginalLength, ZSTD_CCtx *ctx=NULL, z_stream* z = NULL);
|
||||||
@@ -107,7 +107,7 @@ void IOCPUDPServer::WorkerThread()
|
|||||||
|
|
||||||
IO_CONTEXT* ioCtx = CONTAINING_RECORD(pOverlapped, IO_CONTEXT, ol);
|
IO_CONTEXT* ioCtx = CONTAINING_RECORD(pOverlapped, IO_CONTEXT, ol);
|
||||||
CONTEXT_UDP* ctx = ioCtx->pContext;
|
CONTEXT_UDP* ctx = ioCtx->pContext;
|
||||||
BOOL ret = ParseReceivedData(ctx, bytes, m_notify);
|
BOOL ret = ParseReceivedData(ctx, (PBYTE)ctx->szBuffer, bytes, m_notify);
|
||||||
if (999 != ret)
|
if (999 != ret)
|
||||||
ctx->Destroy();
|
ctx->Destroy();
|
||||||
|
|
||||||
|
|||||||
@@ -1126,6 +1126,10 @@ VOID CScreenSpyDlg::OnReceiveComplete()
|
|||||||
case TOKEN_CLIPBOARD_TEXT: {
|
case TOKEN_CLIPBOARD_TEXT: {
|
||||||
Buffer str = m_ContextObject->InDeCompressedBuffer.GetMyBuffer(1);
|
Buffer str = m_ContextObject->InDeCompressedBuffer.GetMyBuffer(1);
|
||||||
UpdateServerClipboard(str.c_str(), str.length());
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case TOKEN_BITMAPINFO: {
|
case TOKEN_BITMAPINFO: {
|
||||||
@@ -3643,7 +3647,7 @@ void CScreenSpyDlg::OnDropFiles(HDROP hDropInfo)
|
|||||||
memcpy((char*)szBuffer + 1 + masterId.length(), hmac.c_str(), hmac.length());
|
memcpy((char*)szBuffer + 1 + masterId.length(), hmac.c_str(), hmac.length());
|
||||||
memcpy(szBuffer + 1 + 80, str.data(), str.size());
|
memcpy(szBuffer + 1 + 80, str.data(), str.size());
|
||||||
auto md5 = CalcMD5FromBytes((BYTE*)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());
|
m_ContextObject->Send2Client(szBuffer, 81 + str.size());
|
||||||
Mprintf("【拖拽】 [本地 -> 远程] V1 传输: %s\n", md5.c_str());
|
Mprintf("【拖拽】 [本地 -> 远程] V1 传输: %s\n", md5.c_str());
|
||||||
SAFE_DELETE_ARRAY(szBuffer);
|
SAFE_DELETE_ARRAY(szBuffer);
|
||||||
|
|||||||
@@ -329,6 +329,17 @@ public:
|
|||||||
#define PREALLOC_DECOMPRESS_SIZE (4 * 1024)
|
#define PREALLOC_DECOMPRESS_SIZE (4 * 1024)
|
||||||
|
|
||||||
#include "context.h"
|
#include "context.h"
|
||||||
|
|
||||||
|
// 每连接传输层状态(单向 TLS,见 docs/TransportTLS_MFCServer_Design.md §5.2)。
|
||||||
|
// 明文与 TLS 客户端在同一端口共存:首包按 ClientHello 头嗅探分派。
|
||||||
|
enum ServerTlsState {
|
||||||
|
TLS_Unclassified = 0, // 尚未收到足够字节判定(缓冲 tlsPreClassify)
|
||||||
|
TLS_Plain = 1, // 明文客户端,直通现有解析路径
|
||||||
|
TLS_Handshaking = 2, // TLS 客户端,握手线程阻塞 AcceptSecurityContext 中
|
||||||
|
TLS_Ready = 3, // TLS 握手完成,收包解密 / 发包加密
|
||||||
|
};
|
||||||
|
class ServerTlsSession; // 定义见 ServerTlsSession.h(避免把 Schannel 头拖进本文件)
|
||||||
|
|
||||||
typedef class CONTEXT_OBJECT : public context
|
typedef class CONTEXT_OBJECT : public context
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -341,6 +352,7 @@ public:
|
|||||||
FreeDecompressBuffer();
|
FreeDecompressBuffer();
|
||||||
FreeCompressBuffer();
|
FreeCompressBuffer();
|
||||||
FreeSendCompressBuffer();
|
FreeSendCompressBuffer();
|
||||||
|
FreeTls();
|
||||||
if (Zcctx) {
|
if (Zcctx) {
|
||||||
ZSTD_freeCCtx(Zcctx);
|
ZSTD_freeCCtx(Zcctx);
|
||||||
Zcctx = nullptr;
|
Zcctx = nullptr;
|
||||||
@@ -377,6 +389,11 @@ public:
|
|||||||
ikcpcb* kcp = nullptr; // 新增,指向KCP会话
|
ikcpcb* kcp = nullptr; // 新增,指向KCP会话
|
||||||
std::string GroupName; // 分组名称
|
std::string GroupName; // 分组名称
|
||||||
CLock SendLock; // fix #214
|
CLock SendLock; // fix #214
|
||||||
|
// 发送是否处于「进行中」状态。OnClientPreSending 每次 Send2Client 都会 Post 一个
|
||||||
|
// IOWrite,若不加节制,OnClientPostSending(0) 会对同一 OutCompressedBuffer 并发发起
|
||||||
|
// 多个 WSASend,读到同一段可变内存(背靠背小包最易触发:resize→COMMAND_NEXT→resize)。
|
||||||
|
// 置位后抑制多余的 IOWrite,待缓冲排空(最后一个 WSASend 完成)再清零。仅受 SendLock 保护。
|
||||||
|
bool bSendPending = false;
|
||||||
time_t OnlineTime = 0; // 上线时间
|
time_t OnlineTime = 0; // 上线时间
|
||||||
time_t LastHeartbeatTime = 0; // 最后心跳时间
|
time_t LastHeartbeatTime = 0; // 最后心跳时间
|
||||||
|
|
||||||
@@ -392,6 +409,11 @@ public:
|
|||||||
// 试用版反代理逐连接检测器。仅由 IOCPServer 的 RTT 轮询线程访问,免锁。
|
// 试用版反代理逐连接检测器。仅由 IOCPServer 的 RTT 轮询线程访问,免锁。
|
||||||
TcpRttBreachDetector m_RttDetector;
|
TcpRttBreachDetector m_RttDetector;
|
||||||
|
|
||||||
|
// ---- 单向 TLS 状态(跨线程:握手线程写入 TLS_Ready,工作线程读取;用 atomic 保序)----
|
||||||
|
std::atomic<int> tlsState{ TLS_Unclassified }; // ServerTlsState
|
||||||
|
ServerTlsSession* tls = nullptr; // 每连接 TLS 会话(握手线程独占→就绪后共用)
|
||||||
|
std::vector<BYTE> tlsPreClassify; // 分类前缓冲的首批字节(嗅探 ClientHello 头)
|
||||||
|
|
||||||
// 子连接身份校验:客户端发 TOKEN_CONN_AUTH 通过验证后置位。
|
// 子连接身份校验:客户端发 TOKEN_CONN_AUTH 通过验证后置位。
|
||||||
// 主连接(走 TOKEN_LOGIN 流程)不参与此机制。当前阶段宽容(未通过也接受),
|
// 主连接(走 TOKEN_LOGIN 流程)不参与此机制。当前阶段宽容(未通过也接受),
|
||||||
// 仅作为标记供后续命令处理 / 未来收紧策略使用。
|
// 仅作为标记供后续命令处理 / 未来收紧策略使用。
|
||||||
@@ -495,6 +517,9 @@ public:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 释放每连接 TLS 会话。定义在 ServerTlsSession.cpp(需 ServerTlsSession 完整类型才能 delete)。
|
||||||
|
void FreeTls();
|
||||||
|
|
||||||
std::string GetProtocol() const override
|
std::string GetProtocol() const override
|
||||||
{
|
{
|
||||||
return Parser.m_Masker && Parser.m_Masker->GetMaskType() == MaskTypeNone ? "TCP" : "HTTP";
|
return Parser.m_Masker && Parser.m_Masker->GetMaskType() == MaskTypeNone ? "TCP" : "HTTP";
|
||||||
@@ -539,6 +564,12 @@ public:
|
|||||||
m_RttUs.store(0, std::memory_order_release);
|
m_RttUs.store(0, std::memory_order_release);
|
||||||
m_LastRttSampleMs.store(0, std::memory_order_release);
|
m_LastRttSampleMs.store(0, std::memory_order_release);
|
||||||
m_RttDetector.Reset();
|
m_RttDetector.Reset();
|
||||||
|
// 复用对象池时重置 TLS 状态:释放旧会话、回到未分类、清空嗅探缓冲。
|
||||||
|
FreeTls();
|
||||||
|
tlsState.store(TLS_Unclassified, std::memory_order_release);
|
||||||
|
tlsPreClassify.clear();
|
||||||
|
// 复用对象池时清空发送进行中标志,避免上次连接残留的 bSendPending 抑制新连接的发送。
|
||||||
|
bSendPending = false;
|
||||||
}
|
}
|
||||||
void SetAuthenticated(bool v) { m_bAuthenticated.store(v, std::memory_order_release); }
|
void SetAuthenticated(bool v) { m_bAuthenticated.store(v, std::memory_order_release); }
|
||||||
bool IsAuthenticated() const { return m_bAuthenticated.load(std::memory_order_acquire); }
|
bool IsAuthenticated() const { return m_bAuthenticated.load(std::memory_order_acquire); }
|
||||||
|
|||||||
@@ -0,0 +1,604 @@
|
|||||||
|
// ServerTlsSession.cpp — 服务端单向 TLS(Schannel 入站)实现
|
||||||
|
//
|
||||||
|
// 与客户端 TlsChannel.cpp 对称,但走 AcceptSecurityContext 入站握手、DecryptMessage/
|
||||||
|
// EncryptMessage 流式加解密。证书/私钥加载为 PEM + CNG(NCryptImportKey PKCS#8),
|
||||||
|
// 以便与 Go 服务端(server/go 的 crypto/tls,YAMA_TLS_CERT/KEY)共用同一对密钥。
|
||||||
|
#include "StdAfx.h"
|
||||||
|
#include "ServerTlsSession.h"
|
||||||
|
#include "Server.h" // CONTEXT_OBJECT::FreeTls()
|
||||||
|
#include "common/iniFile.h" // config(GetInt/GetStr)
|
||||||
|
|
||||||
|
#include <ncrypt.h>
|
||||||
|
#include <bcrypt.h>
|
||||||
|
|
||||||
|
#include <fstream>
|
||||||
|
#include <iterator>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#pragma comment(lib, "secur32.lib")
|
||||||
|
#pragma comment(lib, "crypt32.lib")
|
||||||
|
#pragma comment(lib, "ncrypt.lib")
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// 进程级身份(进程启动加载一次;未来 UI/指令热切换时改为引用计数 + 惰性释放)。
|
||||||
|
std::mutex g_identityMtx;
|
||||||
|
bool g_enabled = false;
|
||||||
|
bool g_ready = false;
|
||||||
|
bool g_loadError = false;
|
||||||
|
CredHandle g_cred = {};
|
||||||
|
bool g_credValid = false;
|
||||||
|
PCCERT_CONTEXT g_cert = NULL; // 与入站凭据同生命周期,FreeIdentity 时释放
|
||||||
|
NCRYPT_KEY_HANDLE g_persistedKey = 0; // 已持久化到 CNG KSP 的私钥句柄,FreeIdentity 时删除
|
||||||
|
|
||||||
|
// 持久化私钥在 CNG KSP 中的名字。用固定名 + NCRYPT_OVERWRITE_KEY_FLAG,重启时覆盖旧键,
|
||||||
|
// 避免残留;同名键的密钥材料与 server.key 相同,覆盖无副作用。
|
||||||
|
const wchar_t* g_keyName = L"YamaServerTlsKey";
|
||||||
|
|
||||||
|
// 一次性读文件为 std::string(二进制安全)。
|
||||||
|
bool ReadFileText(const std::string& path, std::string& out)
|
||||||
|
{
|
||||||
|
std::ifstream f(path, std::ios::binary);
|
||||||
|
if (!f.is_open())
|
||||||
|
return false;
|
||||||
|
std::ostringstream ss;
|
||||||
|
ss << f.rdbuf();
|
||||||
|
if (f.bad())
|
||||||
|
return false;
|
||||||
|
out = ss.str();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PEM(-----BEGIN ...----- 包裹的 base64)解码为 DER。失败返回 false。
|
||||||
|
// CRYPT_STRING_ANY 会自动识别带头的 base64(PEM),对证书与 PKCS#8 私钥均适用。
|
||||||
|
bool PemToDer(const std::string& pem, std::vector<BYTE>& der)
|
||||||
|
{
|
||||||
|
if (pem.empty())
|
||||||
|
return false;
|
||||||
|
DWORD need = 0;
|
||||||
|
if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_ANY,
|
||||||
|
NULL, &need, NULL, NULL))
|
||||||
|
return false;
|
||||||
|
der.resize(need);
|
||||||
|
return CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_ANY,
|
||||||
|
der.data(), &need, NULL, NULL) == TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 PEM 文本里定位私钥块的类型,用于给出可操作的错误提示。
|
||||||
|
enum KeyFmt { KF_PKCS8, KF_PKCS1, KF_UNKNOWN };
|
||||||
|
KeyFmt DetectKeyFormat(const std::string& keyPem)
|
||||||
|
{
|
||||||
|
if (keyPem.find("-----BEGIN PRIVATE KEY-----") != std::string::npos)
|
||||||
|
return KF_PKCS8;
|
||||||
|
if (keyPem.find("-----BEGIN RSA PRIVATE KEY-----") != std::string::npos ||
|
||||||
|
keyPem.find("-----BEGIN EC PRIVATE KEY-----") != std::string::npos)
|
||||||
|
return KF_PKCS1;
|
||||||
|
return KF_UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================================
|
||||||
|
// ServerTlsIdentity —— 进程级服务端身份(可重入加载,禁止 call_once,供未来热切换)
|
||||||
|
// =====================================================================================
|
||||||
|
|
||||||
|
void ServerTlsIdentity::FreeIdentity()
|
||||||
|
{
|
||||||
|
// 先释放凭据,再释放证书上下文,最后删除持久化私钥(凭据/证书可能仍按名引用该键)。
|
||||||
|
if (g_credValid)
|
||||||
|
{
|
||||||
|
FreeCredentialsHandle(&g_cred);
|
||||||
|
g_credValid = false;
|
||||||
|
}
|
||||||
|
g_cred = {};
|
||||||
|
if (g_cert)
|
||||||
|
{
|
||||||
|
CertFreeCertificateContext(g_cert);
|
||||||
|
g_cert = NULL;
|
||||||
|
}
|
||||||
|
if (g_persistedKey)
|
||||||
|
{
|
||||||
|
NCryptDeleteKey(g_persistedKey, 0); // 删除并释放句柄
|
||||||
|
g_persistedKey = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ServerTlsIdentity::Load(config& cfg)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(g_identityMtx);
|
||||||
|
|
||||||
|
bool enabled = cfg.GetInt("tls", "Enabled", 0) != 0;
|
||||||
|
std::string certPath = cfg.GetStr("tls", "CertPath", "");
|
||||||
|
std::string keyPath = cfg.GetStr("tls", "KeyPath", "");
|
||||||
|
|
||||||
|
g_enabled = enabled;
|
||||||
|
|
||||||
|
// 先释放旧身份(本轮只在启动时调用一次,不存在「旧身份仍被并发握手使用」的问题;
|
||||||
|
// 未来热切换需先引用计数、再惰性释放旧凭据)。
|
||||||
|
FreeIdentity();
|
||||||
|
g_ready = false;
|
||||||
|
g_loadError = false;
|
||||||
|
|
||||||
|
if (!enabled)
|
||||||
|
{
|
||||||
|
Mprintf("[TLS] server TLS disabled ([tls] Enabled=0) — plaintext only\n");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certPath.empty() || keyPath.empty())
|
||||||
|
{
|
||||||
|
g_loadError = true;
|
||||||
|
Mprintf("[TLS] FATAL: [tls] Enabled=1 but CertPath/KeyPath empty; "
|
||||||
|
"refusing TLS ClientHello (fail-closed)\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!LoadIdentity(certPath, keyPath))
|
||||||
|
{
|
||||||
|
g_loadError = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_ready = true;
|
||||||
|
Mprintf("[TLS] server TLS enabled: cert=%s key=%s\n", certPath.c_str(), keyPath.c_str());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ServerTlsIdentity::LoadIdentity(const std::string& certPath, const std::string& keyPath)
|
||||||
|
{
|
||||||
|
std::string certPem, keyPem;
|
||||||
|
if (!ReadFileText(certPath, certPem))
|
||||||
|
{
|
||||||
|
Mprintf("[TLS] FATAL: cannot read cert file %s\n", certPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!ReadFileText(keyPath, keyPem))
|
||||||
|
{
|
||||||
|
Mprintf("[TLS] FATAL: cannot read key file %s\n", keyPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 证书:PEM → DER → PCCERT_CONTEXT。
|
||||||
|
std::vector<BYTE> certDer;
|
||||||
|
if (!PemToDer(certPem, certDer))
|
||||||
|
{
|
||||||
|
Mprintf("[TLS] FATAL: cert file %s is not a valid PEM\n", certPath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
PCCERT_CONTEXT cert = CertCreateCertificateContext(X509_ASN_ENCODING,
|
||||||
|
certDer.data(), (DWORD)certDer.size());
|
||||||
|
if (cert == NULL)
|
||||||
|
{
|
||||||
|
Mprintf("[TLS] FATAL: CertCreateCertificateContext failed (%u)\n", GetLastError());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 私钥:PEM → DER → 持久化到 CNG KSP(仅支持 PKCS#8;PKCS#1 给出可操作的转换提示)。
|
||||||
|
//
|
||||||
|
// 关键:Schannel 入站凭据(AcquireCredentialsHandle)需要「能按名重开」的持久化私钥。
|
||||||
|
// NCryptImportKey 产出的是匿名临时键,绑定 CERT_NCRYPT_KEY_HANDLE_PROP_ID 后 Schannel 仍
|
||||||
|
// 拿不到(AcquireCredentialsHandle 返回 SEC_E_NO_CREDENTIALS 0x8009030e)。因此先把
|
||||||
|
// PKCS#8 导入为临时键、导出为裸 RSA 私钥 BLOB(NCryptSetProperty 只认裸 BLOB,不认
|
||||||
|
// PKCS#8),再写入持久化键并 finalize,最后用 CERT_KEY_PROV_INFO_PROP_ID 按名引用。
|
||||||
|
std::vector<BYTE> keyDer;
|
||||||
|
NCRYPT_KEY_HANDLE persistedKey = 0;
|
||||||
|
if (DetectKeyFormat(keyPem) == KF_PKCS8)
|
||||||
|
{
|
||||||
|
if (!PemToDer(keyPem, keyDer))
|
||||||
|
{
|
||||||
|
Mprintf("[TLS] FATAL: key file %s is not a valid PEM\n", keyPath.c_str());
|
||||||
|
CertFreeCertificateContext(cert);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 显式打开 Microsoft Software Key Storage Provider。hProvider 传 NULL 会让
|
||||||
|
// NCryptImportKey 走「默认提供者」解析,实测返回 NTE_INVALID_HANDLE(0x80090026),
|
||||||
|
// 故必须显式取得 KSP 句柄(临时键独立于 hProv,导入后即可释放)。
|
||||||
|
NCRYPT_PROV_HANDLE hProv = 0;
|
||||||
|
SECURITY_STATUS st = NCryptOpenStorageProvider(&hProv, MS_KEY_STORAGE_PROVIDER, 0);
|
||||||
|
if (st != ERROR_SUCCESS)
|
||||||
|
{
|
||||||
|
Mprintf("[TLS] FATAL: NCryptOpenStorageProvider failed 0x%08x\n", (unsigned)st);
|
||||||
|
CertFreeCertificateContext(cert);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2a) PKCS#8 → 临时键。
|
||||||
|
NCRYPT_KEY_HANDLE tmp = 0;
|
||||||
|
st = NCryptImportKey(hProv, NULL, NCRYPT_PKCS8_PRIVATE_KEY_BLOB, NULL,
|
||||||
|
&tmp, keyDer.data(), (DWORD)keyDer.size(), 0);
|
||||||
|
if (st == ERROR_SUCCESS)
|
||||||
|
{
|
||||||
|
// 2b) 允许明文导出,导出为裸 RSA 私钥 BLOB。
|
||||||
|
DWORD policy = NCRYPT_ALLOW_PLAINTEXT_EXPORT_FLAG | NCRYPT_ALLOW_EXPORT_FLAG;
|
||||||
|
NCryptSetProperty(tmp, NCRYPT_EXPORT_POLICY_PROPERTY, (PBYTE)&policy, sizeof(policy), 0);
|
||||||
|
|
||||||
|
std::vector<BYTE> raw;
|
||||||
|
DWORD rawLen = 0;
|
||||||
|
st = NCryptExportKey(tmp, NULL, BCRYPT_RSAFULLPRIVATE_BLOB, NULL, NULL, 0, &rawLen, 0);
|
||||||
|
if (st == ERROR_SUCCESS)
|
||||||
|
{
|
||||||
|
raw.resize(rawLen);
|
||||||
|
st = NCryptExportKey(tmp, NULL, BCRYPT_RSAFULLPRIVATE_BLOB, NULL,
|
||||||
|
raw.data(), rawLen, &rawLen, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2c) 写入持久化键并 finalize(落到 KSP,可被 Schannel 按名重开)。
|
||||||
|
if (st == ERROR_SUCCESS)
|
||||||
|
st = NCryptCreatePersistedKey(hProv, &persistedKey, BCRYPT_RSA_ALGORITHM,
|
||||||
|
g_keyName, AT_KEYEXCHANGE, NCRYPT_OVERWRITE_KEY_FLAG);
|
||||||
|
if (st == ERROR_SUCCESS)
|
||||||
|
st = NCryptSetProperty(persistedKey, BCRYPT_RSAFULLPRIVATE_BLOB,
|
||||||
|
raw.data(), (DWORD)raw.size(), 0);
|
||||||
|
if (st == ERROR_SUCCESS)
|
||||||
|
st = NCryptFinalizeKey(persistedKey, 0);
|
||||||
|
|
||||||
|
NCryptFreeObject(tmp);
|
||||||
|
}
|
||||||
|
NCryptFreeObject(hProv);
|
||||||
|
|
||||||
|
if (st != ERROR_SUCCESS)
|
||||||
|
{
|
||||||
|
Mprintf("[TLS] FATAL: failed to persist PKCS#8 key for %s (0x%08x)\n",
|
||||||
|
keyPath.c_str(), (unsigned)st);
|
||||||
|
if (persistedKey)
|
||||||
|
NCryptFreeObject(persistedKey);
|
||||||
|
CertFreeCertificateContext(cert);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// PKCS#1(RSA/EC PRIVATE KEY)或未知格式:本次不支持,给转换命令。
|
||||||
|
Mprintf("[TLS] FATAL: key file %s must be PKCS#8 (-----BEGIN PRIVATE KEY-----). "
|
||||||
|
"Convert with: openssl pkcs8 -topk8 -nocrypt -in %s -out %s\n",
|
||||||
|
keyPath.c_str(), keyPath.c_str(), keyPath.c_str());
|
||||||
|
CertFreeCertificateContext(cert);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 绑定私钥到证书上下文:按名引用持久化键。Schannel 经 CryptAcquireCertificatePrivateKey
|
||||||
|
// 在 MS_KEY_STORAGE_PROVIDER 里用 pwszContainerName 重开该键。
|
||||||
|
CRYPT_KEY_PROV_INFO provInfo = {};
|
||||||
|
provInfo.pwszContainerName = (LPWSTR)g_keyName;
|
||||||
|
provInfo.pwszProvName = (LPWSTR)MS_KEY_STORAGE_PROVIDER;
|
||||||
|
provInfo.dwProvType = 0; // 0 = CNG(无 CAPI provider type)
|
||||||
|
provInfo.dwKeySpec = AT_KEYEXCHANGE;
|
||||||
|
if (!CertSetCertificateContextProperty(cert, CERT_KEY_PROV_INFO_PROP_ID, 0, &provInfo))
|
||||||
|
{
|
||||||
|
Mprintf("[TLS] FATAL: CertSetCertificateContextProperty(CERT_KEY_PROV_INFO_PROP_ID) "
|
||||||
|
"failed (%u)\n", GetLastError());
|
||||||
|
NCryptDeleteKey(persistedKey, 0); // 删除并释放句柄
|
||||||
|
CertFreeCertificateContext(cert);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 绑定后持久化键句柄交进程级 g_persistedKey,随 FreeIdentity 删除(覆盖旧键,避免残留)。
|
||||||
|
g_persistedKey = persistedKey;
|
||||||
|
|
||||||
|
// 4) 入站凭据:显式指定服务端证书,不请求客户端证书(单向 TLS)。
|
||||||
|
SCHANNEL_CRED sc = {};
|
||||||
|
sc.dwVersion = SCHANNEL_CRED_VERSION;
|
||||||
|
sc.cCreds = 1;
|
||||||
|
sc.paCred = &cert;
|
||||||
|
sc.grbitEnabledProtocols = SP_PROT_TLS1_2_SERVER;
|
||||||
|
// NO_DEFAULT_CREDS 避免回退到系统默认凭据,确保只用我们提供的证书/私钥。
|
||||||
|
//(单向 TLS 不请求客户端证书,故无需 SCH_CRED_MANUAL_CRED_VALIDATION。)
|
||||||
|
sc.dwFlags = SCH_CRED_NO_DEFAULT_CREDS;
|
||||||
|
|
||||||
|
TimeStamp tsExpiry = {};
|
||||||
|
SECURITY_STATUS st = AcquireCredentialsHandleA(
|
||||||
|
NULL, (SEC_CHAR*)"Microsoft Unified Security Protocol Provider", SECPKG_CRED_INBOUND,
|
||||||
|
NULL, &sc, NULL, NULL, &g_cred, &tsExpiry);
|
||||||
|
|
||||||
|
if (st != SEC_E_OK)
|
||||||
|
{
|
||||||
|
Mprintf("[TLS] FATAL: AcquireCredentialsHandle(SECPKG_CRED_INBOUND) failed 0x%08x\n",
|
||||||
|
(unsigned)st);
|
||||||
|
CertFreeCertificateContext(cert); // 无凭据引用,安全释放
|
||||||
|
g_cred = {};
|
||||||
|
if (g_persistedKey) // 回滚:删除已持久化的私钥,避免残留
|
||||||
|
{
|
||||||
|
NCryptDeleteKey(g_persistedKey, 0); // 删除并释放句柄
|
||||||
|
g_persistedKey = 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功:把证书上下文生命周期交给进程级 g_cert,随 FreeIdentity 释放,
|
||||||
|
// 保证在 FreeCredentialsHandle 之前私钥一直可用。
|
||||||
|
g_cert = cert;
|
||||||
|
g_credValid = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ServerTlsIdentity::IsEnabled()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(g_identityMtx);
|
||||||
|
return g_enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ServerTlsIdentity::IsReady()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(g_identityMtx);
|
||||||
|
return g_ready && g_credValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ServerTlsIdentity::IsLoadError()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(g_identityMtx);
|
||||||
|
return g_loadError;
|
||||||
|
}
|
||||||
|
|
||||||
|
CredHandle ServerTlsIdentity::Cred()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(g_identityMtx);
|
||||||
|
return g_cred;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================================
|
||||||
|
// ServerTlsSession —— 单连接会话(握手 + 加解密)
|
||||||
|
// =====================================================================================
|
||||||
|
|
||||||
|
void ServerTlsSession::CloseHandles()
|
||||||
|
{
|
||||||
|
if (m_ctxValid)
|
||||||
|
{
|
||||||
|
DeleteSecurityContext(&m_ctx);
|
||||||
|
m_ctxValid = false;
|
||||||
|
}
|
||||||
|
m_ctx = {};
|
||||||
|
m_ready = false;
|
||||||
|
m_raw.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 握手线程在 IOCP 的子套接字上做同步 I/O。该套接字可能是非阻塞的(监听套接字经
|
||||||
|
// WSAEventSelect 置为非阻塞后,accept 出的子套接字继承非阻塞态;线程里 FIONBIO=0 也
|
||||||
|
// 不一定即时生效)。故 recv/send 遇 WSAEWOULDBLOCK 时用 select 等可读/可写再重试,
|
||||||
|
// 而非当致命错误,避免「客户端回包还没到就误判握手失败」。
|
||||||
|
static bool WaitSocketReady(SOCKET sock, bool forRead, int timeoutMs)
|
||||||
|
{
|
||||||
|
fd_set fds;
|
||||||
|
FD_ZERO(&fds);
|
||||||
|
FD_SET(sock, &fds);
|
||||||
|
timeval tv;
|
||||||
|
tv.tv_sec = timeoutMs / 1000;
|
||||||
|
tv.tv_usec = (timeoutMs % 1000) * 1000;
|
||||||
|
return select(0, forRead ? &fds : NULL, forRead ? NULL : &fds, NULL, &tv) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ServerTlsSession::SendAll(SOCKET sock, const void* data, int len)
|
||||||
|
{
|
||||||
|
const BYTE* p = (const BYTE*)data;
|
||||||
|
int sent = 0;
|
||||||
|
while (sent < len)
|
||||||
|
{
|
||||||
|
int n = send(sock, (const char*)p + sent, len - sent, 0);
|
||||||
|
if (n == SOCKET_ERROR && WSAGetLastError() == WSAEWOULDBLOCK)
|
||||||
|
{
|
||||||
|
if (!WaitSocketReady(sock, false, 15000))
|
||||||
|
return false;
|
||||||
|
continue; // 非阻塞套接字发送缓冲满:等可写后重试
|
||||||
|
}
|
||||||
|
if (n <= 0)
|
||||||
|
return false;
|
||||||
|
sent += n;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ServerTlsSession::Handshake(SOCKET sock, const BYTE* initial, int initialLen)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(m_mtx);
|
||||||
|
if (m_ctxValid)
|
||||||
|
CloseHandles(); // 防御:会话应只握手一次
|
||||||
|
|
||||||
|
CredHandle cred = ServerTlsIdentity::Cred();
|
||||||
|
|
||||||
|
// 单向 TLS:服务端不请求客户端证书,故无需 ASC_REQ_MANUAL_CRED_VALIDATION
|
||||||
|
//(该标志只在 mTLS 校验客户端证书时有意义;SDK 的 ASC 命名空间里亦无此常量)。
|
||||||
|
DWORD req = ASC_REQ_SEQUENCE_DETECT | ASC_REQ_REPLAY_DETECT | ASC_REQ_CONFIDENTIALITY |
|
||||||
|
ASC_REQ_ALLOCATE_MEMORY | ASC_REQ_STREAM;
|
||||||
|
|
||||||
|
std::vector<BYTE> hs(initial, initial + initialLen);
|
||||||
|
CtxtHandle ctx = {};
|
||||||
|
bool ctxValid = false;
|
||||||
|
TimeStamp tsExpiry = {};
|
||||||
|
ULONG attrs = 0;
|
||||||
|
SECURITY_STATUS sc = SEC_I_CONTINUE_NEEDED;
|
||||||
|
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
SecBuffer inBufs[2] = {
|
||||||
|
{ (ULONG)hs.size(), SECBUFFER_TOKEN, hs.empty() ? NULL : hs.data() },
|
||||||
|
{ 0, SECBUFFER_EMPTY, NULL },
|
||||||
|
};
|
||||||
|
SecBufferDesc inDesc = { SECBUFFER_VERSION, 2, inBufs };
|
||||||
|
SecBuffer outBuf = { 0, SECBUFFER_TOKEN, NULL };
|
||||||
|
SecBufferDesc outDesc = { SECBUFFER_VERSION, 1, &outBuf };
|
||||||
|
|
||||||
|
sc = AcceptSecurityContext(&cred, ctxValid ? &ctx : NULL, &inDesc, req,
|
||||||
|
SECURITY_NATIVE_DREP, ctxValid ? NULL : &ctx,
|
||||||
|
&outDesc, &attrs, &tsExpiry);
|
||||||
|
ctxValid = true;
|
||||||
|
|
||||||
|
// 发出服务端握手记录(ServerHello/Certificate/... 或 Finished)。
|
||||||
|
if (outBuf.cbBuffer > 0 && outBuf.pvBuffer)
|
||||||
|
{
|
||||||
|
bool ok = SendAll(sock, outBuf.pvBuffer, (int)outBuf.cbBuffer);
|
||||||
|
FreeContextBuffer(outBuf.pvBuffer);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
sc = SEC_E_INTERNAL_ERROR;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算已消费字节:SECBUFFER_EXTRA 里是「未消费的尾部」(下一条握手消息 / 首条应用数据)。
|
||||||
|
// 必须在 SEC_E_OK / 失败判定之前处理:否则握手完成时 hs 里仍混着已消费的握手记录,
|
||||||
|
// 紧跟 Finished 的首条应用数据(客户端登录包)会随 hs 一起被丢弃,导致随机丢包。
|
||||||
|
if (inBufs[1].BufferType == SECBUFFER_EXTRA)
|
||||||
|
{
|
||||||
|
int extra = (int)inBufs[1].cbBuffer;
|
||||||
|
hs.erase(hs.begin(), hs.begin() + (hs.size() - extra));
|
||||||
|
}
|
||||||
|
else if (sc != SEC_E_INCOMPLETE_MESSAGE && sc != SEC_I_INCOMPLETE_CREDENTIALS)
|
||||||
|
{
|
||||||
|
hs.clear(); // 已全部消费,继续等下一轮握手消息
|
||||||
|
}
|
||||||
|
// 否则:SEC_E_INCOMPLETE_MESSAGE —— 保留 hs(不完整消息),续收补齐。
|
||||||
|
|
||||||
|
if (sc == SEC_E_OK)
|
||||||
|
break;
|
||||||
|
if (FAILED(sc) && sc != SEC_E_INCOMPLETE_MESSAGE && sc != SEC_I_INCOMPLETE_CREDENTIALS)
|
||||||
|
{
|
||||||
|
Mprintf("[TLS] AcceptSecurityContext failed 0x%08x (in=%u consumed? extra=%u)\n",
|
||||||
|
(unsigned)sc, inBufs[0].cbBuffer, inBufs[1].BufferType == SECBUFFER_EXTRA ? inBufs[1].cbBuffer : 0);
|
||||||
|
break; // 真正的失败
|
||||||
|
}
|
||||||
|
|
||||||
|
char raw[8192];
|
||||||
|
int r;
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
r = recv(sock, raw, sizeof(raw), 0);
|
||||||
|
if (r == SOCKET_ERROR && WSAGetLastError() == WSAEWOULDBLOCK)
|
||||||
|
{
|
||||||
|
if (!WaitSocketReady(sock, true, 15000))
|
||||||
|
{
|
||||||
|
r = SOCKET_ERROR;
|
||||||
|
break; // 等可读超时
|
||||||
|
}
|
||||||
|
continue; // 非阻塞套接字暂无数据:等可读后重试
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (r <= 0)
|
||||||
|
{
|
||||||
|
Mprintf("[TLS] handshake recv failed/EOF (r=%d err=%d)\n", r, WSAGetLastError());
|
||||||
|
sc = SEC_E_INTERNAL_ERROR;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hs.insert(hs.end(), (BYTE*)raw, (BYTE*)raw + r);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sc != SEC_E_OK)
|
||||||
|
{
|
||||||
|
Mprintf("[TLS] handshake failed: final status 0x%08x (fail-closed)\n", (unsigned)sc);
|
||||||
|
if (ctxValid)
|
||||||
|
DeleteSecurityContext(&ctx);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 握手成功后 hs 里残留的「未消费尾部」是紧跟 Finished 的首条应用数据(TLS 记录)。
|
||||||
|
// 交给 Decrypt 处理,否则首包会丢(表现为随机登录超时/重连/协议错位)。
|
||||||
|
if (!hs.empty())
|
||||||
|
m_raw.insert(m_raw.end(), hs.begin(), hs.end());
|
||||||
|
|
||||||
|
// 查询流式记录尺寸(加密封装用)。
|
||||||
|
SecPkgContext_StreamSizes sizes = {};
|
||||||
|
if (QueryContextAttributes(&ctx, SECPKG_ATTR_STREAM_SIZES, &sizes) != SEC_E_OK)
|
||||||
|
{
|
||||||
|
DeleteSecurityContext(&ctx);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_ctx = ctx;
|
||||||
|
m_ctxValid = true;
|
||||||
|
m_sizes = sizes;
|
||||||
|
m_ready = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ServerTlsSession::Decrypt(const BYTE* cipher, int len, std::vector<BYTE>& plain)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(m_mtx);
|
||||||
|
if (!m_ready)
|
||||||
|
return -1;
|
||||||
|
if (len <= 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
m_raw.insert(m_raw.end(), cipher, cipher + len);
|
||||||
|
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
SecBuffer bufs[4] = {
|
||||||
|
{ (ULONG)m_raw.size(), SECBUFFER_DATA, m_raw.empty() ? NULL : m_raw.data() },
|
||||||
|
{ 0, SECBUFFER_EMPTY, NULL },
|
||||||
|
{ 0, SECBUFFER_EMPTY, NULL },
|
||||||
|
{ 0, SECBUFFER_EMPTY, NULL },
|
||||||
|
};
|
||||||
|
SecBufferDesc desc = { SECBUFFER_VERSION, 4, bufs };
|
||||||
|
|
||||||
|
SECURITY_STATUS sc = DecryptMessage(&m_ctx, &desc, 0, NULL);
|
||||||
|
if (sc == SEC_E_INCOMPLETE_MESSAGE)
|
||||||
|
break; // 记录不完整,等下一轮 WSARecv
|
||||||
|
if (sc == SEC_I_CONTEXT_EXPIRED) {
|
||||||
|
Mprintf("[TLS] DecryptMessage: peer close_notify (SEC_I_CONTEXT_EXPIRED)\n");
|
||||||
|
return -1; // 对端 close_notify / 会话结束
|
||||||
|
}
|
||||||
|
if (sc == SEC_I_RENEGOTIATE || FAILED(sc)) {
|
||||||
|
Mprintf("[TLS] DecryptMessage failed 0x%08x (raw=%u)\n", (unsigned)sc, (unsigned)m_raw.size());
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int decLen = (int)bufs[1].cbBuffer;
|
||||||
|
const BYTE* p = (const BYTE*)bufs[1].pvBuffer;
|
||||||
|
int extra = (bufs[3].BufferType == SECBUFFER_EXTRA) ? (int)bufs[3].cbBuffer : 0;
|
||||||
|
int consumed = (int)m_raw.size() - extra;
|
||||||
|
|
||||||
|
if (decLen > 0 && p)
|
||||||
|
plain.insert(plain.end(), p, p + decLen);
|
||||||
|
|
||||||
|
m_raw.erase(m_raw.begin(), m_raw.begin() + consumed);
|
||||||
|
if (m_raw.empty())
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ServerTlsSession::Encrypt(const BYTE* plain, int len, std::vector<BYTE>& wire)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(m_mtx);
|
||||||
|
if (!m_ready)
|
||||||
|
return -1;
|
||||||
|
if (len <= 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
ULONG maxMsg = m_sizes.cbMaximumMessage > 0 ? m_sizes.cbMaximumMessage : 16384;
|
||||||
|
int total = 0;
|
||||||
|
while (total < len)
|
||||||
|
{
|
||||||
|
int chunk = (len - total > (int)maxMsg) ? (int)maxMsg : (len - total);
|
||||||
|
|
||||||
|
std::vector<BYTE> rec(m_sizes.cbHeader + chunk + m_sizes.cbTrailer);
|
||||||
|
BYTE* hdr = rec.data();
|
||||||
|
BYTE* body = hdr + m_sizes.cbHeader;
|
||||||
|
BYTE* trailer = body + chunk;
|
||||||
|
memcpy(body, plain + total, chunk);
|
||||||
|
|
||||||
|
SecBuffer bufs[4] = {
|
||||||
|
{ m_sizes.cbHeader, SECBUFFER_STREAM_HEADER, hdr },
|
||||||
|
{ (ULONG)chunk, SECBUFFER_DATA, body },
|
||||||
|
{ m_sizes.cbTrailer, SECBUFFER_STREAM_TRAILER, trailer },
|
||||||
|
{ 0, SECBUFFER_EMPTY, NULL },
|
||||||
|
};
|
||||||
|
SecBufferDesc desc = { SECBUFFER_VERSION, 4, bufs };
|
||||||
|
|
||||||
|
SECURITY_STATUS sc = EncryptMessage(&m_ctx, 0, &desc, 0);
|
||||||
|
if (FAILED(sc))
|
||||||
|
return -1; // 握手已建立,此处失败视为致命(调用方关闭连接)
|
||||||
|
|
||||||
|
int wireLen = (int)(bufs[0].cbBuffer + bufs[1].cbBuffer + bufs[2].cbBuffer);
|
||||||
|
wire.insert(wire.end(), rec.data(), rec.data() + wireLen);
|
||||||
|
total += chunk;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================================
|
||||||
|
// CONTEXT_OBJECT::FreeTls —— 释放每连接 TLS 会话(定义在此以便 delete 完整类型)
|
||||||
|
// =====================================================================================
|
||||||
|
|
||||||
|
void CONTEXT_OBJECT::FreeTls()
|
||||||
|
{
|
||||||
|
delete tls;
|
||||||
|
tls = NULL;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// ServerTlsSession.h — MFC 服务端单向 TLS(Schannel 入站)会话封装
|
||||||
|
//
|
||||||
|
// 与客户端 TlsChannel 对称:客户端走 InitializeSecurityContext(出站),本类走
|
||||||
|
// AcceptSecurityContext(入站)。单向 TLS:不请求/校验客户端证书,客户端身份仍由
|
||||||
|
// 隧道内登录(V1 PwdHmac / V2 ECDSA)承担。
|
||||||
|
//
|
||||||
|
// 分层:
|
||||||
|
// ServerTlsIdentity —— 进程级「服务端身份」(证书 + 私钥 → 入站凭据)。可重入加载
|
||||||
|
// (Load 禁止用 call_once,供未来 UI/指令热切换),本轮启动时加载一次。
|
||||||
|
// ServerTlsSession —— 每条连接一个,持有握手/加解密所需的 Schannel 上下文与缓冲,
|
||||||
|
// 随 CONTEXT_OBJECT 池化复用而重建。
|
||||||
|
//
|
||||||
|
// 线程模型:Handshake 在专用握手线程上阻塞执行(方案 A);握手完成后,Decrypt 由
|
||||||
|
// IOCP 工作线程调用(每连接同一时刻只有一个未决 WSARecv,天然串行)、Encrypt 在
|
||||||
|
// SendLock 内调用。Schannel 上下文非线程安全,故 Encrypt/Decrypt 内部以 m_mtx 互斥。
|
||||||
|
//
|
||||||
|
// 选型:Schannel(Windows 原生 SSPI),secur32.lib / crypt32.lib / ncrypt.lib 系统库。
|
||||||
|
// 证书/私钥为 PEM(与 Go 服务端 YAMA_TLS_CERT/KEY 同构,可共用同一对密钥);私钥
|
||||||
|
// 须为 PKCS#8(-----BEGIN PRIVATE KEY-----),见 ServerTlsSession.cpp 的加载实现。
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <WinSock2.h>
|
||||||
|
|
||||||
|
#ifndef SECURITY_WIN32
|
||||||
|
#define SECURITY_WIN32 1
|
||||||
|
#endif
|
||||||
|
#include <security.h>
|
||||||
|
#include <schannel.h>
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
class config; // common/iniFile.h(仅需引用,避免把 ini 头拖进 Server.h 的 include 图)
|
||||||
|
|
||||||
|
// ---- 进程级服务端身份(证书 + 私钥),可重入加载(禁止 call_once,供未来热切换)----
|
||||||
|
class ServerTlsIdentity
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// 从 cfg(THIS_CFG)读 [tls] 节:Enabled / CertPath / KeyPath,并加载身份。
|
||||||
|
// 返回 true 表示「配置已生效」(开关关、或开关开且身份加载成功);开关开但加载
|
||||||
|
// 失败时返回 false 且 IsLoadError()==true,服务端须对 TLS ClientHello fail-closed。
|
||||||
|
static bool Load(config& cfg);
|
||||||
|
|
||||||
|
static bool IsEnabled(); // [tls] Enabled != 0
|
||||||
|
static bool IsReady(); // 开关开 && 身份加载成功(有可用入站凭据)
|
||||||
|
static bool IsLoadError(); // 开关开 && 身份加载失败(fail-closed)
|
||||||
|
|
||||||
|
// 入站凭据(AcquireCredentialsHandle 产出)。返回拷贝;句柄值本身可安全拷贝。
|
||||||
|
static CredHandle Cred();
|
||||||
|
|
||||||
|
private:
|
||||||
|
static void FreeIdentity();
|
||||||
|
static bool LoadIdentity(const std::string& certPath, const std::string& keyPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 单连接服务端 TLS 会话(握手 + 加解密)----
|
||||||
|
class ServerTlsSession
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
ServerTlsSession() {}
|
||||||
|
~ServerTlsSession() { CloseHandles(); }
|
||||||
|
|
||||||
|
ServerTlsSession(const ServerTlsSession&) = delete;
|
||||||
|
ServerTlsSession& operator=(const ServerTlsSession&) = delete;
|
||||||
|
|
||||||
|
// 阻塞式服务端握手(方案 A:专用握手线程上调用)。initial/initialLen 是嗅探阶段已
|
||||||
|
// 收到的 ClientHello 字节(可能只是分片;内部按 SEC_E_INCOMPLETE_MESSAGE 续收补齐)。
|
||||||
|
// 成功返回 true,失败 false(调用方关闭连接,fail-closed)。
|
||||||
|
bool Handshake(SOCKET sock, const BYTE* initial, int initialLen);
|
||||||
|
|
||||||
|
// 解密:把本次 WSARecv 收到的密文 cipher[0..len] 喂入,把能解出的明文追加到 plain。
|
||||||
|
// 返回 0 = 成功(可能无明文);-1 = 错误 / 对端 close_notify(调用方关闭连接)。
|
||||||
|
int Decrypt(const BYTE* cipher, int len, std::vector<BYTE>& plain);
|
||||||
|
|
||||||
|
// 加密:把明文 plain[0..len] 加密成完整 TLS 记录,追加到 wire。
|
||||||
|
// 返回加密的明文字节数(== len);-1 = 错误。
|
||||||
|
int Encrypt(const BYTE* plain, int len, std::vector<BYTE>& wire);
|
||||||
|
|
||||||
|
bool Ready() const { return m_ready; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void CloseHandles();
|
||||||
|
bool SendAll(SOCKET sock, const void* data, int len);
|
||||||
|
|
||||||
|
std::mutex m_mtx; // 序列化对 m_ctx 的 Encrypt/Decrypt(Schannel 非线程安全)
|
||||||
|
CtxtHandle m_ctx = {};
|
||||||
|
bool m_ctxValid = false;
|
||||||
|
bool m_ready = false;
|
||||||
|
SecPkgContext_StreamSizes m_sizes = {};
|
||||||
|
std::vector<BYTE> m_raw; // 尚未解密 / 未消费完的 TLS 记录原始字节
|
||||||
|
};
|
||||||
@@ -349,6 +349,75 @@ public:
|
|||||||
void onHttp(HttpHandler handler) { m_httpHandler = handler; }
|
void onHttp(HttpHandler handler) { m_httpHandler = handler; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// ---- Real client IP behind a reverse proxy ----
|
||||||
|
// A same-host nginx/Caddy connects from 127.0.0.1 and reports the original
|
||||||
|
// peer in X-Real-IP / X-Forwarded-For. Only trust those headers when the
|
||||||
|
// TCP peer is loopback: otherwise any internet client could forge its
|
||||||
|
// address in the audit log and rotate it to evade login throttling.
|
||||||
|
static std::string trimHttpValue(const std::string& s) {
|
||||||
|
size_t b = s.find_first_not_of(" \t");
|
||||||
|
if (b == std::string::npos) return "";
|
||||||
|
size_t e = s.find_last_not_of(" \t\r\n");
|
||||||
|
return s.substr(b, e - b + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string asciiLower(const std::string& s) {
|
||||||
|
std::string out = s;
|
||||||
|
for (char& c : out) {
|
||||||
|
if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive lookup of one header in the raw request. The request
|
||||||
|
// line is first, so matching "\r\n<name>:" cannot hit the request target.
|
||||||
|
static std::string httpHeader(const std::string& request, const char* name) {
|
||||||
|
const std::string needle = "\r\n" + asciiLower(name) + ":";
|
||||||
|
const std::string hay = asciiLower(request);
|
||||||
|
size_t pos = hay.find(needle);
|
||||||
|
if (pos == std::string::npos) return "";
|
||||||
|
size_t vstart = pos + needle.size();
|
||||||
|
size_t vend = hay.find("\r\n", vstart);
|
||||||
|
return trimHttpValue(request.substr(vstart,
|
||||||
|
vend == std::string::npos ? std::string::npos : vend - vstart));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept only a bare IPv4/IPv6 literal; forwarded headers are attacker
|
||||||
|
// controlled even when the proxy is trusted, so no hostnames/ports.
|
||||||
|
static std::string normalizeIPLiteral(const std::string& value) {
|
||||||
|
std::string s = trimHttpValue(value);
|
||||||
|
if (s.empty() || s.size() > 63) return "";
|
||||||
|
struct in_addr v4;
|
||||||
|
if (inet_pton(AF_INET, s.c_str(), &v4) == 1) return s;
|
||||||
|
struct in6_addr v6;
|
||||||
|
if (inet_pton(AF_INET6, s.c_str(), &v6) == 1) return s;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string forwardedClientIP(const std::string& request) {
|
||||||
|
// X-Forwarded-For is "client, proxy1, ..." with the oldest hop first.
|
||||||
|
// Our own proxy appends the peer it saw, so the last valid entry is
|
||||||
|
// the one it actually observed: a client-supplied list cannot forge
|
||||||
|
// it. Prefer this over X-Real-IP, which is only trustworthy when the
|
||||||
|
// proxy explicitly overwrites it (some configs pass it through).
|
||||||
|
std::string xff = httpHeader(request, "x-forwarded-for");
|
||||||
|
size_t end = xff.size();
|
||||||
|
while (end > 0) {
|
||||||
|
size_t comma = xff.rfind(',', end - 1);
|
||||||
|
size_t start = comma == std::string::npos ? 0 : comma + 1;
|
||||||
|
std::string ip = normalizeIPLiteral(xff.substr(start, end - start));
|
||||||
|
if (!ip.empty()) return ip;
|
||||||
|
if (comma == std::string::npos) break;
|
||||||
|
end = comma;
|
||||||
|
}
|
||||||
|
return normalizeIPLiteral(httpHeader(request, "x-real-ip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isLoopbackPeer(const std::string& ip) {
|
||||||
|
return ip.rfind("127.", 0) == 0 || ip == "::1" ||
|
||||||
|
ip == "::ffff:127.0.0.1";
|
||||||
|
}
|
||||||
|
|
||||||
// Parse Proxy Protocol v2 header and extract real client IP
|
// Parse Proxy Protocol v2 header and extract real client IP
|
||||||
bool parseProxyProtocolV2(SOCKET sock, std::string& clientIP) {
|
bool parseProxyProtocolV2(SOCKET sock, std::string& clientIP) {
|
||||||
// PP2 signature (12 bytes)
|
// PP2 signature (12 bytes)
|
||||||
@@ -448,6 +517,13 @@ private:
|
|||||||
if (request.find("\r\n\r\n") != std::string::npos) break;
|
if (request.find("\r\n\r\n") != std::string::npos) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Behind a same-host reverse proxy the TCP peer is 127.0.0.1; recover
|
||||||
|
// the real client address the proxy recorded in the handshake.
|
||||||
|
if (isLoopbackPeer(clientIP)) {
|
||||||
|
std::string forwarded = forwardedClientIP(request);
|
||||||
|
if (!forwarded.empty()) clientIP = forwarded;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if WebSocket upgrade
|
// Check if WebSocket upgrade
|
||||||
if (request.find("Upgrade: websocket") == std::string::npos) {
|
if (request.find("Upgrade: websocket") == std::string::npos) {
|
||||||
// Regular HTTP request - extract path
|
// Regular HTTP request - extract path
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
#include "stdafx.h"
|
||||||
|
#include "TlsCertificateDlg.h"
|
||||||
|
#include "LangManager.h" // _TR / MessageBoxL
|
||||||
|
#include "2015Remote.h" // THIS_CFG (GetThisCfg)
|
||||||
|
#include "ServerTlsSession.h" // ServerTlsIdentity::Load / IsLoadError
|
||||||
|
#include "common/iniFile.h" // config::GetInt/GetStr/SetInt/SetStr
|
||||||
|
#include <atlconv.h>
|
||||||
|
#include <afxdlgs.h>
|
||||||
|
|
||||||
|
#ifdef _DEBUG
|
||||||
|
#define new DEBUG_NEW
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// 构造一个无控件的 DLGTEMPLATE:cdit=0,控件由 OnInitDialog 动态创建。
|
||||||
|
// (与 McpSettingsDlg.cpp / ZstaPickerDlg.cpp 内同名函数一致)
|
||||||
|
void BuildDialogTemplate(std::vector<BYTE>& out, LPCWSTR caption, short cx, short cy)
|
||||||
|
{
|
||||||
|
out.clear();
|
||||||
|
auto append = [&](const void* p, size_t n) {
|
||||||
|
const BYTE* b = (const BYTE*)p;
|
||||||
|
out.insert(out.end(), b, b + n);
|
||||||
|
};
|
||||||
|
auto appendW = [&](WORD v) {
|
||||||
|
out.push_back((BYTE)(v & 0xFF));
|
||||||
|
out.push_back((BYTE)((v >> 8) & 0xFF));
|
||||||
|
};
|
||||||
|
auto appendWStr = [&](LPCWSTR s) {
|
||||||
|
size_t n = wcslen(s);
|
||||||
|
append(s, (n + 1) * sizeof(WCHAR));
|
||||||
|
};
|
||||||
|
|
||||||
|
DLGTEMPLATE dt = { 0 };
|
||||||
|
dt.style = DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER |
|
||||||
|
WS_POPUP | WS_CAPTION | WS_SYSMENU;
|
||||||
|
dt.dwExtendedStyle = 0;
|
||||||
|
dt.cdit = 0;
|
||||||
|
dt.x = 0; dt.y = 0;
|
||||||
|
dt.cx = cx; dt.cy = cy;
|
||||||
|
append(&dt, sizeof(dt));
|
||||||
|
appendW(0); // no menu
|
||||||
|
appendW(0); // default dialog class
|
||||||
|
appendWStr(caption); // caption
|
||||||
|
appendW(8); // font point size
|
||||||
|
appendWStr(L"MS Shell Dlg"); // typeface
|
||||||
|
|
||||||
|
while (out.size() % 4) out.push_back(0); // DWORD align
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
BEGIN_MESSAGE_MAP(CTlsCertificateDlg, CDialog)
|
||||||
|
ON_BN_CLICKED(IDC_TLS_CERT_BROWSE, &CTlsCertificateDlg::OnBrowseCert)
|
||||||
|
ON_BN_CLICKED(IDC_TLS_KEY_BROWSE, &CTlsCertificateDlg::OnBrowseKey)
|
||||||
|
END_MESSAGE_MAP()
|
||||||
|
|
||||||
|
CTlsCertificateDlg::CTlsCertificateDlg(CWnd* parent)
|
||||||
|
: CDialog((LPCTSTR)NULL, parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
INT_PTR CTlsCertificateDlg::DoModal()
|
||||||
|
{
|
||||||
|
USES_CONVERSION;
|
||||||
|
CString title = _TR("TLS证书");
|
||||||
|
BuildDialogTemplate(m_Template, T2CW(title), 340, 180);
|
||||||
|
InitModalIndirect((LPCDLGTEMPLATE)m_Template.data());
|
||||||
|
return CDialog::DoModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL CTlsCertificateDlg::OnInitDialog()
|
||||||
|
{
|
||||||
|
CDialog::OnInitDialog();
|
||||||
|
|
||||||
|
CRect cli;
|
||||||
|
GetClientRect(&cli);
|
||||||
|
|
||||||
|
// 占位 rect,真正布局在 LayoutControls 里
|
||||||
|
CRect r0(0, 0, 10, 10);
|
||||||
|
|
||||||
|
m_btnEnable.Create(_TR("启用加密"),
|
||||||
|
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX,
|
||||||
|
r0, this, IDC_TLS_ENABLE);
|
||||||
|
m_lblCert.Create(_TR("证书路径"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1);
|
||||||
|
m_editCert.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL,
|
||||||
|
r0, this, IDC_TLS_CERT);
|
||||||
|
m_btnCertBrowse.Create(_TR("浏览"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON,
|
||||||
|
r0, this, IDC_TLS_CERT_BROWSE);
|
||||||
|
m_lblKey.Create(_TR("私钥路径"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1);
|
||||||
|
m_editKey.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL,
|
||||||
|
r0, this, IDC_TLS_KEY);
|
||||||
|
m_btnKeyBrowse.Create(_TR("浏览"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON,
|
||||||
|
r0, this, IDC_TLS_KEY_BROWSE);
|
||||||
|
m_btnOK.Create(_TR("确定"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_DEFPUSHBUTTON,
|
||||||
|
r0, this, IDOK);
|
||||||
|
m_btnCancel.Create(_TR("取消"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON,
|
||||||
|
r0, this, IDCANCEL);
|
||||||
|
|
||||||
|
// 子控件继承对话框字体 (DS_SETFONT)
|
||||||
|
HFONT hFont = (HFONT)::SendMessage(GetSafeHwnd(), WM_GETFONT, 0, 0);
|
||||||
|
if (hFont) {
|
||||||
|
m_btnEnable.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
m_lblCert.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
m_editCert.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
m_btnCertBrowse.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
m_lblKey.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
m_editKey.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
m_btnKeyBrowse.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
m_btnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
m_btnCancel.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回填配置
|
||||||
|
int enabled = THIS_CFG.GetInt("tls", "Enabled", 0);
|
||||||
|
std::string cert = THIS_CFG.GetStr("tls", "CertPath", "");
|
||||||
|
std::string key = THIS_CFG.GetStr("tls", "KeyPath", "");
|
||||||
|
|
||||||
|
m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED);
|
||||||
|
m_editCert.SetWindowText(CString(cert.c_str()));
|
||||||
|
m_editKey.SetWindowText(CString(key.c_str()));
|
||||||
|
|
||||||
|
LayoutControls(cli.Width(), cli.Height());
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CTlsCertificateDlg::OnOK()
|
||||||
|
{
|
||||||
|
CString sCert, sKey;
|
||||||
|
m_editCert.GetWindowText(sCert);
|
||||||
|
m_editKey.GetWindowText(sKey);
|
||||||
|
bool enabled = (m_btnEnable.GetCheck() == BST_CHECKED);
|
||||||
|
|
||||||
|
std::string cert = CT2A(sCert);
|
||||||
|
std::string key = CT2A(sKey);
|
||||||
|
|
||||||
|
// 校验:启用加密时必须填写证书/私钥路径
|
||||||
|
if (enabled && cert.empty()) {
|
||||||
|
MessageBoxL("请填写证书路径", "提示", MB_ICONWARNING);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (enabled && key.empty()) {
|
||||||
|
MessageBoxL("请填写私钥路径", "提示", MB_ICONWARNING);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 落盘 [tls] 三键
|
||||||
|
THIS_CFG.SetInt("tls", "Enabled", enabled ? 1 : 0);
|
||||||
|
THIS_CFG.SetStr("tls", "CertPath", cert);
|
||||||
|
THIS_CFG.SetStr("tls", "KeyPath", key);
|
||||||
|
|
||||||
|
// 热重载身份(设计文档 §7.1):开关与证书/私钥路径对新建连接即时生效。
|
||||||
|
// 已知窄竞态:Load 内部 FreeIdentity 与「已取 Cred 副本、尚在握手」的连接存在竞态,
|
||||||
|
// 未来热切换需引用计数 + 惰性释放(见 ServerTlsSession.cpp FreeIdentity 注释),本次不做。
|
||||||
|
ServerTlsIdentity::Load(THIS_CFG);
|
||||||
|
|
||||||
|
if (ServerTlsIdentity::IsLoadError()) {
|
||||||
|
MessageBoxL("证书/私钥加载失败,加密功能未生效。请检查文件路径与格式(证书 PEM、私钥 PKCS#8)。",
|
||||||
|
"提示", MB_ICONWARNING);
|
||||||
|
} else {
|
||||||
|
MessageBox(_TR("TLS 配置已保存。") + _T("\r\n") +
|
||||||
|
_TR("改动对新建连接即时生效。"),
|
||||||
|
_TR("提示"), MB_ICONINFORMATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
CDialog::OnOK();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CTlsCertificateDlg::OnBrowseCert()
|
||||||
|
{
|
||||||
|
CFileDialog dlg(TRUE, _T("crt"), nullptr,
|
||||||
|
OFN_FILEMUSTEXIST | OFN_HIDEREADONLY,
|
||||||
|
_T("Certificate Files (*.crt;*.pem;*.cer;*.cert)|*.crt;*.pem;*.cer;*.cert|All Files (*.*)|*.*||"),
|
||||||
|
this);
|
||||||
|
dlg.m_ofn.lpstrTitle = _T("Select Certificate");
|
||||||
|
|
||||||
|
if (dlg.DoModal() == IDOK) {
|
||||||
|
m_editCert.SetWindowText(dlg.GetPathName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CTlsCertificateDlg::OnBrowseKey()
|
||||||
|
{
|
||||||
|
CFileDialog dlg(TRUE, _T("key"), nullptr,
|
||||||
|
OFN_FILEMUSTEXIST | OFN_HIDEREADONLY,
|
||||||
|
_T("Key Files (*.key;*.pem)|*.key;*.pem|All Files (*.*)|*.*||"),
|
||||||
|
this);
|
||||||
|
dlg.m_ofn.lpstrTitle = _T("Select Private Key");
|
||||||
|
|
||||||
|
if (dlg.DoModal() == IDOK) {
|
||||||
|
m_editKey.SetWindowText(dlg.GetPathName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CTlsCertificateDlg::LayoutControls(int cx, int cy)
|
||||||
|
{
|
||||||
|
const int margin = 14;
|
||||||
|
const int labelW = 72;
|
||||||
|
const int rowH = 24;
|
||||||
|
const int gap = 10;
|
||||||
|
const int browseW = 64;
|
||||||
|
const int btnW = 88;
|
||||||
|
const int btnH = 26;
|
||||||
|
|
||||||
|
int y = margin;
|
||||||
|
m_btnEnable.MoveWindow(margin, y, cx - margin * 2, 22);
|
||||||
|
y += 30;
|
||||||
|
|
||||||
|
int editW = cx - margin * 2 - labelW - browseW - gap;
|
||||||
|
|
||||||
|
m_lblCert.MoveWindow(margin, y, labelW, rowH);
|
||||||
|
m_editCert.MoveWindow(margin + labelW, y - 2, editW, rowH);
|
||||||
|
m_btnCertBrowse.MoveWindow(cx - margin - browseW, y - 2, browseW, rowH);
|
||||||
|
y += rowH + gap;
|
||||||
|
|
||||||
|
m_lblKey.MoveWindow(margin, y, labelW, rowH);
|
||||||
|
m_editKey.MoveWindow(margin + labelW, y - 2, editW, rowH);
|
||||||
|
m_btnKeyBrowse.MoveWindow(cx - margin - browseW, y - 2, browseW, rowH);
|
||||||
|
y += rowH + gap;
|
||||||
|
|
||||||
|
// 确定 / 取消按钮紧随私钥路径下方
|
||||||
|
m_btnCancel.MoveWindow(cx - margin - btnW, y, btnW, btnH);
|
||||||
|
m_btnOK.MoveWindow(cx - margin - btnW * 2 - gap, y, btnW, btnH);
|
||||||
|
y += btnH + margin;
|
||||||
|
|
||||||
|
// 收缩对话框到内容实际高度并重新居中,消除底部空白。
|
||||||
|
if (y < cy) {
|
||||||
|
CRect wnd;
|
||||||
|
GetWindowRect(&wnd);
|
||||||
|
SetWindowPos(NULL, 0, 0, wnd.Width(), y + (wnd.Height() - cy),
|
||||||
|
SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||||
|
CenterWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <afxwin.h>
|
||||||
|
#include <afxcmn.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// 「TLS证书」配置对话框:启用服务端单向 TLS 加密 + 指定证书/私钥路径。
|
||||||
|
// 因 2015Remote.rc 为 UTF-16 编码、不宜手工编辑,参照 CMcpSettingsDlg 用
|
||||||
|
// InitModalIndirect + 内存 DLGTEMPLATE 程序化创建(见 docs/TransportTLS_MFCServer_Design.md §7.1)。
|
||||||
|
class CTlsCertificateDlg : public CDialog
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit CTlsCertificateDlg(CWnd* parent = nullptr);
|
||||||
|
|
||||||
|
virtual INT_PTR DoModal();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual BOOL OnInitDialog();
|
||||||
|
virtual void OnOK();
|
||||||
|
|
||||||
|
DECLARE_MESSAGE_MAP()
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum CtrlId {
|
||||||
|
IDC_TLS_ENABLE = 2001, // 「启用加密」复选框
|
||||||
|
IDC_TLS_CERT = 2002, // 证书路径编辑框
|
||||||
|
IDC_TLS_CERT_BROWSE = 2003, // 证书路径「浏览...」按钮
|
||||||
|
IDC_TLS_KEY = 2004, // 私钥路径编辑框
|
||||||
|
IDC_TLS_KEY_BROWSE = 2005, // 私钥路径「浏览...」按钮
|
||||||
|
};
|
||||||
|
|
||||||
|
CButton m_btnEnable;
|
||||||
|
CStatic m_lblCert, m_lblKey;
|
||||||
|
CEdit m_editCert, m_editKey;
|
||||||
|
CButton m_btnCertBrowse, m_btnKeyBrowse;
|
||||||
|
CButton m_btnOK, m_btnCancel;
|
||||||
|
|
||||||
|
std::vector<BYTE> m_Template; // 内存 DLGTEMPLATE 字节
|
||||||
|
|
||||||
|
void LayoutControls(int cx, int cy);
|
||||||
|
void OnBrowseCert();
|
||||||
|
void OnBrowseKey();
|
||||||
|
};
|
||||||
@@ -172,6 +172,7 @@
|
|||||||
#define HIDE_MENU_PLUGIN_REQUEST 0 // 插件请求
|
#define HIDE_MENU_PLUGIN_REQUEST 0 // 插件请求
|
||||||
#define HIDE_MENU_FRPS_FOR_SUB 0 // 下级FRP
|
#define HIDE_MENU_FRPS_FOR_SUB 0 // 下级FRP
|
||||||
#define HIDE_MENU_MCP_SETTINGS 0 // MCP设置
|
#define HIDE_MENU_MCP_SETTINGS 0 // MCP设置
|
||||||
|
#define HIDE_MENU_TLS_CERTIFICATE 0 // TLS证书
|
||||||
|
|
||||||
// --- 扩展菜单 - 语言子菜单 ---
|
// --- 扩展菜单 - 语言子菜单 ---
|
||||||
#define HIDE_MENU_CHANGE_LANG 0 // 更改语言
|
#define HIDE_MENU_CHANGE_LANG 0 // 更改语言
|
||||||
|
|||||||
+849
-116
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,8 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <set>
|
#include <set>
|
||||||
|
#include <initializer_list>
|
||||||
|
#include <utility>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -68,12 +70,20 @@ struct WebClient {
|
|||||||
std::string token;
|
std::string token;
|
||||||
std::string username;
|
std::string username;
|
||||||
std::string role; // "admin" | "viewer"
|
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 connected_at;
|
||||||
uint64_t last_activity; // Last message/pong received (for heartbeat timeout)
|
uint64_t last_activity; // Last message/pong received (for heartbeat timeout)
|
||||||
std::string client_ip;
|
std::string client_ip;
|
||||||
|
// Per-connection audit de-duplication: probes an attacker can repeat in a
|
||||||
|
// tight loop are recorded once per connection instead of flooding the ring.
|
||||||
|
bool salt_probe_logged = false;
|
||||||
|
bool login_throttle_logged = false;
|
||||||
|
|
||||||
WebClient() : watch_device_id(0), connected_at(0), last_activity(0) {}
|
WebClient() : connected_at(0), last_activity(0) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Login attempt tracking for rate limiting
|
// Login attempt tracking for rate limiting
|
||||||
@@ -160,6 +170,9 @@ public:
|
|||||||
// Cursor change notification (called from ScreenSpyDlg)
|
// Cursor change notification (called from ScreenSpyDlg)
|
||||||
void BroadcastCursor(uint64_t device_id, uint8_t cursor_index);
|
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
|
// Get count of web clients watching a device
|
||||||
int GetWebClientCount(uint64_t device_id);
|
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 HandleLogin(void* ws_ptr, const std::string& msg, const std::string& client_ip);
|
||||||
void HandleGetSalt(void* ws_ptr, const std::string& msg);
|
void HandleGetSalt(void* ws_ptr, const std::string& msg);
|
||||||
void HandleGetDevices(void* ws_ptr, const std::string& token);
|
void HandleGetDevices(void* ws_ptr, const std::string& token);
|
||||||
void HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id);
|
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);
|
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 HandlePing(void* ws_ptr, const std::string& token);
|
||||||
void HandleMouse(void* ws_ptr, const std::string& msg);
|
void HandleMouse(void* ws_ptr, const std::string& msg);
|
||||||
void HandleKey(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 HandleClipboardSet(void* ws_ptr, const std::string& msg);
|
||||||
void HandleAudioToggle(void* ws_ptr, const std::string& token);
|
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
|
// Token management
|
||||||
std::string GenerateToken(const std::string& username, const std::string& role);
|
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 HandleListUsers(void* ws_ptr, const std::string& token);
|
||||||
void HandleGetGroups(void* ws_ptr, const std::string& token);
|
void HandleGetGroups(void* ws_ptr, const std::string& token);
|
||||||
|
|
||||||
|
// Security audit trail: in-memory ring of the last 1000 events since
|
||||||
|
// startup (see WebService.cpp). query_audit is admin-only.
|
||||||
|
void HandleQueryAudit(void* ws_ptr, const std::string& msg);
|
||||||
|
std::string ClientIP(void* ws_ptr);
|
||||||
|
void AuditWeb(const std::string& event, bool warn,
|
||||||
|
const std::string& actor, const std::string& role,
|
||||||
|
const std::string& ip, void* ws_ptr, uint64_t device_id,
|
||||||
|
std::initializer_list<std::pair<const char*, std::string>> extra = {});
|
||||||
|
|
||||||
// Send to WebSocket
|
// Send to WebSocket
|
||||||
void SendText(void* ws_ptr, const std::string& text);
|
void SendText(void* ws_ptr, const std::string& text);
|
||||||
void SendBinary(void* ws_ptr, const uint8_t* data, size_t len);
|
void SendBinary(void* ws_ptr, const uint8_t* data, size_t len);
|
||||||
@@ -241,6 +274,10 @@ private:
|
|||||||
static const int HEARTBEAT_INTERVAL_SEC = 30; // Send ping every 30 seconds
|
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
|
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
|
// Heartbeat thread function
|
||||||
void HeartbeatThread();
|
void HeartbeatThread();
|
||||||
|
|
||||||
@@ -366,8 +403,12 @@ private:
|
|||||||
std::set<uint64_t> m_TermPending; // 已发 COMMAND_SHELL 待响应
|
std::set<uint64_t> m_TermPending; // 已发 COMMAND_SHELL 待响应
|
||||||
std::mutex m_TermMutex;
|
std::mutex m_TermMutex;
|
||||||
|
|
||||||
// 内部清理(已持锁版本)
|
// 内部清理(已持锁版本)。reason/actor/role/ip 仅用于审计日志;actor 为空时
|
||||||
void CloseTermSessionLocked(uint64_t device_id);
|
// 尝试从 m_Clients 反查(ws 已注销时需由调用方传入)。
|
||||||
|
void CloseTermSessionLocked(uint64_t device_id, const char* reason = "ws_close",
|
||||||
|
const std::string& actor = std::string(),
|
||||||
|
const std::string& role = std::string(),
|
||||||
|
const std::string& ip = std::string());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Global accessor
|
// Global accessor
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ enum {
|
|||||||
ONLINELIST_STARTTIME,
|
ONLINELIST_STARTTIME,
|
||||||
ONLINELIST_CAPABILITIES, // 客户端能力位
|
ONLINELIST_CAPABILITIES, // 客户端能力位
|
||||||
ONLINELIST_PRIVILEGE, // 权限级别 (SYSTEM/Admin/User)
|
ONLINELIST_PRIVILEGE, // 权限级别 (SYSTEM/Admin/User)
|
||||||
|
ONLINELIST_TLS, // TLS 传输启用标志 (0/1)
|
||||||
ONLINELIST_MAX,
|
ONLINELIST_MAX,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2069,3 +2069,31 @@ JPEG
|
|||||||
%s · 约 %.2f MB/s=%s · ca. %.2f MB/s
|
%s · 约 %.2f MB/s=%s · ca. %.2f MB/s
|
||||||
%s · 不受限速(受网络带宽约束)=%s · Unbegrenzt (durch Netzwerkbandbreite begrenzt)
|
%s · 不受限速(受网络带宽约束)=%s · Unbegrenzt (durch Netzwerkbandbreite begrenzt)
|
||||||
文件传输速度已保存。上传速度重启主控端后生效;下载速度已推送到在线客户端,新连接的主机也会自动获取。=Uebertragungsgeschwindigkeit gespeichert. Upload-Geschwindigkeit wird nach Neustart des Masters wirksam; Download-Geschwindigkeit wurde an Online-Clients gesendet, neu verbundene Hosts erhalten sie automatisch.
|
文件传输速度已保存。上传速度重启主控端后生效;下载速度已推送到在线客户端,新连接的主机也会自动获取。=Uebertragungsgeschwindigkeit gespeichert. Upload-Geschwindigkeit wird nach Neustart des Masters wirksam; Download-Geschwindigkeit wurde an Online-Clients gesendet, neu verbundene Hosts erhalten sie automatisch.
|
||||||
|
|
||||||
|
; ===== TLS =====
|
||||||
|
TLS证书(&T)...=TLS-Zertifikat(&T)...
|
||||||
|
TLS证书=TLS-Zertifikat
|
||||||
|
启用加密=Verschluesselung aktivieren
|
||||||
|
证书路径=Zertifikatspfad
|
||||||
|
私钥路径=Pfad zum privaten Schluessel
|
||||||
|
请填写证书路径=Bitte Zertifikatspfad angeben
|
||||||
|
请填写私钥路径=Bitte Pfad zum privaten Schluessel angeben
|
||||||
|
TLS 配置已保存。=TLS-Einstellungen gespeichert.
|
||||||
|
改动对新建连接即时生效。=Aenderungen gelten sofort fuer neue Verbindungen.
|
||||||
|
证书/私钥加载失败,加密功能未生效。请检查文件路径与格式(证书 PEM、私钥 PKCS#8)。=Zertifikat/privater Schluessel konnte nicht geladen werden; Verschluesselung ist inaktiv. Bitte Pfad und Format pruefen (PEM-Zertifikat, PKCS#8-Schluessel).
|
||||||
|
TLS传输=TLS-Transport
|
||||||
|
取消TLS=TLS deaktivieren
|
||||||
|
确定对选中主机启用 TLS 传输吗?(客户端下次启动生效)=TLS-Transport fuer die ausgewaehlten Hosts aktivieren? (wirkt nach Client-Neustart)
|
||||||
|
确定取消选中主机的 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
|
||||||
@@ -2084,3 +2084,31 @@ JPEG
|
|||||||
连接数:%d=Connections: %d
|
连接数:%d=Connections: %d
|
||||||
强制断开=Force Disconnect
|
强制断开=Force Disconnect
|
||||||
远程交谈=Talk
|
远程交谈=Talk
|
||||||
|
|
||||||
|
; ===== TLS =====
|
||||||
|
TLS证书(&T)...=TLS Certificate(&T)...
|
||||||
|
TLS证书=TLS Certificate
|
||||||
|
启用加密=Enable Encryption
|
||||||
|
证书路径=Certificate Path
|
||||||
|
私钥路径=Private Key Path
|
||||||
|
请填写证书路径=Please specify the certificate path
|
||||||
|
请填写私钥路径=Please specify the private key path
|
||||||
|
TLS 配置已保存。=TLS settings saved.
|
||||||
|
改动对新建连接即时生效。=Changes take effect immediately for new connections.
|
||||||
|
证书/私钥加载失败,加密功能未生效。请检查文件路径与格式(证书 PEM、私钥 PKCS#8)。=Failed to load certificate/private key; encryption is not active. Check the file path and format (PEM certificate, PKCS#8 private key).
|
||||||
|
TLS传输=TLS Transport
|
||||||
|
取消TLS=Disable TLS
|
||||||
|
确定对选中主机启用 TLS 传输吗?(客户端下次启动生效)=Enable TLS transport for the selected host? (takes effect after the client restarts)
|
||||||
|
确定取消选中主机的 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
|
||||||
@@ -2075,3 +2075,31 @@ JPEG
|
|||||||
连接数:%d=連線數:%d
|
连接数:%d=連線數:%d
|
||||||
强制断开=強制中斷
|
强制断开=強制中斷
|
||||||
远程交谈=遠程交談
|
远程交谈=遠程交談
|
||||||
|
|
||||||
|
; ===== TLS =====
|
||||||
|
TLS证书(&T)...=TLS 憑證(&T)...
|
||||||
|
TLS证书=TLS 憑證
|
||||||
|
启用加密=啟用加密
|
||||||
|
证书路径=憑證路徑
|
||||||
|
私钥路径=私鑰路徑
|
||||||
|
请填写证书路径=請填寫憑證路徑
|
||||||
|
请填写私钥路径=請填寫私鑰路徑
|
||||||
|
TLS 配置已保存。=TLS 設定已儲存。
|
||||||
|
改动对新建连接即时生效。=變更對新連線即時生效。
|
||||||
|
证书/私钥加载失败,加密功能未生效。请检查文件路径与格式(证书 PEM、私钥 PKCS#8)。=憑證/私鑰載入失敗,加密功能未生效。請檢查檔案路徑與格式(憑證 PEM、私鑰 PKCS#8)。
|
||||||
|
TLS传输=TLS 傳輸
|
||||||
|
取消TLS=停用 TLS
|
||||||
|
确定对选中主机启用 TLS 传输吗?(客户端下次启动生效)=確定對選取主機啟用 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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -287,6 +287,8 @@
|
|||||||
#define IDB_BITMAP_FILE_MANAGER 407
|
#define IDB_BITMAP_FILE_MANAGER 407
|
||||||
#define IDB_BITMAP_PRIVACY_SHIELD 408
|
#define IDB_BITMAP_PRIVACY_SHIELD 408
|
||||||
#define IDB_BITMAP_DRAWING_BOARD 409
|
#define IDB_BITMAP_DRAWING_BOARD 409
|
||||||
|
#define IDB_BITMAP_TLS_ENABLE 410
|
||||||
|
#define IDB_BITMAP_TLS_DISABLE 411
|
||||||
#define IDC_MESSAGE 1000
|
#define IDC_MESSAGE 1000
|
||||||
#define IDC_ONLINE 1001
|
#define IDC_ONLINE 1001
|
||||||
#define IDC_STATIC_TIPS 1002
|
#define IDC_STATIC_TIPS 1002
|
||||||
@@ -1008,9 +1010,11 @@
|
|||||||
#define ID_MSGLOG_LOGIN_NOTIFY 33079
|
#define ID_MSGLOG_LOGIN_NOTIFY 33079
|
||||||
#define ID_MSGLOG_BAN_IP 33080
|
#define ID_MSGLOG_BAN_IP 33080
|
||||||
#define ID_MSGLOG_UNBAN_IP 33081
|
#define ID_MSGLOG_UNBAN_IP 33081
|
||||||
|
#define ID_MSGLOG_WHITELIST_IP 33096
|
||||||
#define ID_WEB_REMOTE_CONTROL 33044
|
#define ID_WEB_REMOTE_CONTROL 33044
|
||||||
#define ID_TOOL_PLUGIN_SETTINGS 33045
|
#define ID_TOOL_PLUGIN_SETTINGS 33045
|
||||||
#define ID_MCP_SETTINGS 33077
|
#define ID_MCP_SETTINGS 33077
|
||||||
|
#define ID_TLS_CERTIFICATE 33093
|
||||||
#define ID_CONNECTION_MANAGE 33086
|
#define ID_CONNECTION_MANAGE 33086
|
||||||
#define ID_CONNECTION_KILL 33087
|
#define ID_CONNECTION_KILL 33087
|
||||||
#define ID_CONNECTION_REFRESH 33088
|
#define ID_CONNECTION_REFRESH 33088
|
||||||
@@ -1018,6 +1022,8 @@
|
|||||||
#define ID_EXPERIMENT_FILE_MANAGER 33090
|
#define ID_EXPERIMENT_FILE_MANAGER 33090
|
||||||
#define ID_EXPERIMENT_PRIVATE_SCREEN 33091
|
#define ID_EXPERIMENT_PRIVATE_SCREEN 33091
|
||||||
#define ID_EXPERIMENT_DRAWING_BOARD 33092
|
#define ID_EXPERIMENT_DRAWING_BOARD 33092
|
||||||
|
#define ID_EXPERIMENT_TLS_ENABLE 33094
|
||||||
|
#define ID_EXPERIMENT_TLS_DISABLE 33095
|
||||||
#define ID_33046 33046
|
#define ID_33046 33046
|
||||||
#define ID_PROXY_PORT_AUTORUN 33047
|
#define ID_PROXY_PORT_AUTORUN 33047
|
||||||
#define ID_TRIGGER_SETTINGS 33048
|
#define ID_TRIGGER_SETTINGS 33048
|
||||||
@@ -1114,8 +1120,8 @@
|
|||||||
//
|
//
|
||||||
#ifdef APSTUDIO_INVOKED
|
#ifdef APSTUDIO_INVOKED
|
||||||
#ifndef APSTUDIO_READONLY_SYMBOLS
|
#ifndef APSTUDIO_READONLY_SYMBOLS
|
||||||
#define _APS_NEXT_RESOURCE_VALUE 410
|
#define _APS_NEXT_RESOURCE_VALUE 412
|
||||||
#define _APS_NEXT_COMMAND_VALUE 33093
|
#define _APS_NEXT_COMMAND_VALUE 33097
|
||||||
#define _APS_NEXT_CONTROL_VALUE 2620
|
#define _APS_NEXT_CONTROL_VALUE 2620
|
||||||
#define _APS_NEXT_SYMED_VALUE 105
|
#define _APS_NEXT_SYMED_VALUE 105
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
Vendored
+6
-2
@@ -13,7 +13,9 @@
|
|||||||
"--http-port=8080"
|
"--http-port=8080"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"YAMA_WEB_ADMIN_PASS": "3.14159"
|
"YAMA_WEB_ADMIN_PASS": "3.14159",
|
||||||
|
"YAMA_TLS_CERT": "${workspaceFolder}/certs/server.crt",
|
||||||
|
"YAMA_TLS_KEY": "${workspaceFolder}/certs/server.key"
|
||||||
},
|
},
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"preLaunchTask": "sync-web-assets"
|
"preLaunchTask": "sync-web-assets"
|
||||||
@@ -30,7 +32,9 @@
|
|||||||
"--http-port=8080"
|
"--http-port=8080"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"YAMA_WEB_ADMIN_PASS": "3.14159"
|
"YAMA_WEB_ADMIN_PASS": "3.14159",
|
||||||
|
"YAMA_TLS_CERT": "${workspaceFolder}/certs/server.crt",
|
||||||
|
"YAMA_TLS_KEY": "${workspaceFolder}/certs/server.key"
|
||||||
},
|
},
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"buildFlags": "-gcflags='all=-N -l'",
|
"buildFlags": "-gcflags='all=-N -l'",
|
||||||
|
|||||||
+42
-1
@@ -141,7 +141,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
|
|||||||
./simpleremoter-server
|
./simpleremoter-server
|
||||||
```
|
```
|
||||||
|
|
||||||
默认监听 TCP 端口 `6543`(被控设备),HTTP 端口 `8080`(浏览器 Web UI)。日志写到 `logs/server.log`。
|
默认监听 TCP 端口 `6543`(被控设备),HTTP 端口 `8080`(浏览器 Web UI)。日志写到 `logs/server.log`,安全审计记录另写 `logs/audit.log`。
|
||||||
|
|
||||||
### 命令行参数
|
### 命令行参数
|
||||||
|
|
||||||
@@ -170,6 +170,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
|
|||||||
| `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` |
|
| `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` |
|
||||||
| `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` |
|
| `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` |
|
||||||
| `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` |
|
| `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` |
|
||||||
|
| `YAMA_AUDIT_LOG` | 安全审计日志路径(JSON lines)。默认 `logs/audit.log`;设为 `off` / `none` / `0` / `-` 关闭独立文件(审计记录仍会写入 `logs/server.log`)。审计记录带 `audit=true` 和稳定的 `event` 名,便于告警与检索。 | `/var/log/yama/audit.log` |
|
||||||
| `YAMA_SSO_JWT_SECRET` | **[SSO]** Shared HS256 secret for bridging the parent portal's `auth_token` JWT into a YAMA web session via `/sso`. Must equal the upstream portal's signing key (FMS: `SERVER_PRIVATE_KEY`). **Only `admin` portal users are auto-logged-in**; other roles still need YAMA's own web login. Unset → SSO disabled, `/sso` returns 401. | `<shared-secret>` |
|
| `YAMA_SSO_JWT_SECRET` | **[SSO]** Shared HS256 secret for bridging the parent portal's `auth_token` JWT into a YAMA web session via `/sso`. Must equal the upstream portal's signing key (FMS: `SERVER_PRIVATE_KEY`). **Only `admin` portal users are auto-logged-in**; other roles still need YAMA's own web login. Unset → SSO disabled, `/sso` returns 401. | `<shared-secret>` |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -327,6 +328,46 @@ func main() {
|
|||||||
{"level":"debug","module":"Handler","time":"2025-12-19T13:17:47+01:00","message":"Heartbeat from client 1 (DESKTOP-BI6RGEJ)"}
|
{"level":"debug","module":"Handler","time":"2025-12-19T13:17:47+01:00","message":"Heartbeat from client 1 (DESKTOP-BI6RGEJ)"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 安全审计日志
|
||||||
|
|
||||||
|
Web 端与设备端的认证、授权、远程控制操作会额外写入**独立**的 `logs/audit.log`(可由 `YAMA_AUDIT_LOG` 改路径或关闭),同时镜像到 `logs/server.log`。每行是一个 JSON 对象,固定带 `audit=true` 与 `event`,其余字段按事件携带 `actor` / `role` / `ip` / `peer_ip` / `conn` / `device_id` / `reason` 等。
|
||||||
|
|
||||||
|
网页暴露到公网后,优先关注这些事件:
|
||||||
|
|
||||||
|
| 事件 | 含义 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| `web.ws.connect` / `web.ws.reject` | WebSocket 建立 / 被拒(Origin 不允许、升级失败) |
|
||||||
|
| `web.login.success` / `web.login.failure` / `web.login.throttled` | 登录成功、失败(含 `reason`、`user_exists`)、触发限流 |
|
||||||
|
| `web.salt.unknown_user` | `get_salt` 探测不存在的用户名(用户名枚举信号,每连接记一次) |
|
||||||
|
| `web.auth.invalid_token` / `web.authz.denied` | 无效 token、权限不足(非 admin 调用管理命令) |
|
||||||
|
| `web.session.resume` | 浏览器用缓存的 token 在重连后恢复会话 |
|
||||||
|
| `web.ws.close` | 连接关闭;带 `duration_ms`、`commands` 以及 `invalid_tokens` / `login_failures` / `admin_denied` 等可疑计数 |
|
||||||
|
| `remote.screen.start` / `.stop` / `.denied` | 远程桌面开始 / 结束(`reason`:disconnect、device_offline、ws_close、replaced)/ 被拒 |
|
||||||
|
| `remote.terminal.start` / `.stop` / `.denied` | 远程终端会话生命周期 |
|
||||||
|
| `remote.clipboard.get` / `.set` | 剪贴板读取 / 写入(只记字节数,**不记内容**) |
|
||||||
|
| `remote.rdp_reset` | 远程 RDP 会话归位操作 |
|
||||||
|
| `admin.user.create` / `admin.user.delete` / `admin.device.delete` | 管理操作(`target` / `device_id` / `mode`) |
|
||||||
|
| `admin.audit.query` | 管理员在网页查询审计日志(记录筛选条件与命中数) |
|
||||||
|
| `device.online` / `device.offline` | 受控设备上线 / 下线 |
|
||||||
|
| `device.auth.success` / `.failure` / `.throttled` / `.heartbeat.success` | 设备 TCP 授权结果(**只记 passcode 长度,不记 passcode**) |
|
||||||
|
| `device.connauth.rejected` / `device.subconn.foreign_ip` | 子连接签名失败 / 子连接来源 IP 与主连接不一致 |
|
||||||
|
|
||||||
|
管理员可以在网页里直接查询:设备页工具栏的 **Audit Log** 按钮(`admin` 角色且服务端在 `caps` 中声明 `auditLog` 时显示)打开审计页面,支持按时间范围、事件(精确值或前缀,如 `web.*`)、actor、IP、device_id、关键字筛选以及“仅警告”、分页。页面读取当前 `logs/audit.log`(超大文件只扫描末尾 32 MB,并会提示;已轮转的历史文件仍用下面的 grep/jq 排查),查询动作本身记为 `admin.audit.query`。
|
||||||
|
|
||||||
|
常用排查命令:
|
||||||
|
```bash
|
||||||
|
# 最近的登录失败与被拒操作
|
||||||
|
grep -E '"event":"web\.(login\.failure|login\.throttled|authz\.denied|salt\.unknown_user)"' logs/audit.log | tail -50
|
||||||
|
|
||||||
|
# 按来源 IP 聚合失败次数(找爆破源)
|
||||||
|
grep '"event":"web.login.failure"' logs/audit.log | jq -r '.ip' | sort | uniq -c | sort -rn | head
|
||||||
|
|
||||||
|
# 谁在什么时候开了远程桌面
|
||||||
|
grep '"event":"remote.screen.start"' logs/audit.log | jq -c '{time,actor,role,ip,device_id}'
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:鼠标 / 键盘事件不逐条记录(频率太高,会把审计日志刷爆);审计以会话粒度(`remote.screen.*`、`remote.terminal.*`)体现操作。审计记录中的字符串字段来自客户端(用户名、User-Agent、Origin、设备名等),已做控制字符清理与长度截断,避免伪造日志行。
|
||||||
|
|
||||||
## 协议格式
|
## 协议格式
|
||||||
|
|
||||||
数据包格式与 C++ 版本兼容:
|
数据包格式与 C++ 版本兼容:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 私钥与中间产物不入库;.crt 为公钥证书,可提交
|
||||||
|
*.key
|
||||||
|
*.csr
|
||||||
|
*.srl
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDLDCCAhSgAwIBAgIUPkDGqH/CbV5Qgy3mFPOJ4VwdZq0wDQYJKoZIhvcNAQEL
|
||||||
|
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAxNDEzMDMwMVoXDTM2MTAx
|
||||||
|
MTEzMDMwMVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
||||||
|
AAOCAQ8AMIIBCgKCAQEAh+nNFRRn9pK94OfgUv7hqVPI774HyUoMEzgiAx/XB3Mf
|
||||||
|
UdEkJExvtMnHGT4FSlQ80oi1cIUuh/9svYCyPVwGuY1Bd5Jt8me9pN9CY7kJmFl+
|
||||||
|
MlBv8mz34e5gNwrybd2orn/2RATmVPCgQ82nXRmY8vFqs6KA/Eqh5Sd8S+b2rUs7
|
||||||
|
wzlg80lDQtH/7Bf7egsQ+cYS3Xeex6H82Q9AwVpvljjrst7HI+kxKEifHRsE/amW
|
||||||
|
Bx88evqx8keE0AQ30gyXsbIRgID+LF8jcFf3WzMx+cqYStn9JJG+SlakaIlKw2I6
|
||||||
|
FCES/UTSYoFsk06EaOy+od2+wo6jpfkJCGdEItlJlwIDAQABo3YwdDAgBgNVHREE
|
||||||
|
GTAXgglsb2NhbGhvc3SHBH8AAAGHBAAAAAAwDAYDVR0TAQH/BAIwADAOBgNVHQ8B
|
||||||
|
Af8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYEFBoxa/G+knDe
|
||||||
|
DBs5vl+Nrdxy0uJpMA0GCSqGSIb3DQEBCwUAA4IBAQCBE/Xj13AzPhxZacbdAvpn
|
||||||
|
xNxfF+3wJs9j0qo8w2LcB+x10pe8/ay2Gs0ThZc63o/LqDxlS6LrRvkjRlypM+Ls
|
||||||
|
gahajW0hJs+sXfa+EhcJx6c1IR/uolSpRYuel/Ui6Xvhsr11BTpsgrHZdcYlKZqf
|
||||||
|
75mX0byKRoPVkG/dIVPWpnoFkm8BLJdlZ0OpgGL9iyBVqgH0rw5hejnDZVVH2K4g
|
||||||
|
0ZeE9QUbNl0oS01bpBJfCJfZT70+38yU61TTegpZEzCTBTMKccNU3hKnX7n9C7ZM
|
||||||
|
JMb0lEZ/IuCaOxmpz5zmHPa4bibkAPSbpozOKP3S48zQPDEa58CvSUlVIrmo7+d+
|
||||||
|
-----END CERTIFICATE-----
|
||||||
+291
-41
@@ -2,7 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -15,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/licensing"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/licensing"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||||
@@ -26,12 +30,13 @@ import (
|
|||||||
|
|
||||||
// MyHandler implements the server.Handler interface
|
// MyHandler implements the server.Handler interface
|
||||||
type MyHandler struct {
|
type MyHandler struct {
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
auth *auth.Authenticator
|
auth *auth.Authenticator
|
||||||
srv *server.Server
|
srv *server.Server
|
||||||
hub *hub.Hub
|
hub *hub.Hub
|
||||||
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
|
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
|
||||||
loginLimit *wsauth.RateLimiter // per-IP TOKEN_AUTH throttle
|
loginLimit *wsauth.RateLimiter // per-IP TOKEN_AUTH throttle
|
||||||
|
connAuthKey []byte // nil = ConnAuth HMAC verification disabled (legacy always-OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnConnect is called when a client connects
|
// OnConnect is called when a client connects
|
||||||
@@ -131,17 +136,29 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
|
|||||||
// arrives shortly after via TOKEN_NEXTSCREEN.
|
// arrives shortly after via TOKEN_NEXTSCREEN.
|
||||||
case protocol.TokenNextScreen:
|
case protocol.TokenNextScreen:
|
||||||
h.handleScreenFrame(ctx, data, false)
|
h.handleScreenFrame(ctx, data, false)
|
||||||
|
case protocol.TokenClipboardText:
|
||||||
|
h.handleClipboardText(ctx, data)
|
||||||
case protocol.TokenKeyframe:
|
case protocol.TokenKeyframe:
|
||||||
// Sent by the client only when frameID % m_GOP == 0; the client's
|
// The client switches to this token when it emits an IDR on request
|
||||||
// DEFAULT_GOP is 0x7FFFFFFF (effectively infinite), so this token
|
// (COMMAND_SCREEN_KEYFRAME_REQ) or on a GOP boundary. The header
|
||||||
// is essentially unused in practice. Treat as a no-op for now —
|
// layout is identical to TOKEN_NEXTSCREEN, but the payload is always
|
||||||
// IDRs always arrive in-band via TOKEN_NEXTSCREEN and we catch
|
// an IDR, so relay it as frameType=1 without sniffing. Dropping it
|
||||||
// them via the H264 NAL scan in handleScreenFrame.
|
// (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:
|
case protocol.CmdCursorImage:
|
||||||
// Custom cursor bitmaps — relayed in Phase 5+ when the web cursor
|
// Custom cursor bitmaps — relayed in Phase 5+ when the web cursor
|
||||||
// overlay learns to render arbitrary BGRA images. Drop silently for
|
// overlay learns to render arbitrary BGRA images. Drop silently for
|
||||||
// now; the standard IDC_* index (data[10] of every frame header) is
|
// now; the standard IDC_* index (data[10] of every frame header) is
|
||||||
// what we actually use right now.
|
// 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:
|
default:
|
||||||
// Other commands are not implemented yet
|
// Other commands are not implemented yet
|
||||||
h.log.Info("Unhandled command %d from client %d", cmd, ctx.ID)
|
h.log.Info("Unhandled command %d from client %d", cmd, ctx.ID)
|
||||||
@@ -154,12 +171,25 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
|
|||||||
// on our 256-byte ConnAuthAck. Without an OK reply the client closes the
|
// on our 256-byte ConnAuthAck. Without an OK reply the client closes the
|
||||||
// connection, so a missing ack here means nothing else can proceed.
|
// connection, so a missing ack here means nothing else can proceed.
|
||||||
//
|
//
|
||||||
// The handshake includes an HMAC signature field. The reference server
|
// When the operator supplied YAMA_CONN_AUTH_KEY, the packet's HMAC-SHA256
|
||||||
// treats verification failures as soft (logs and still allows commands),
|
// signature and timestamp are verified first (see verifyConnAuth); a bad
|
||||||
// and the signing primitive lives in a vendored component out of scope
|
// packet is answered with a non-OK status and the sub-conn is dropped.
|
||||||
// for this server, so we always reply OK and let TOKEN_BITMAPINFO carry
|
// Without a key we keep the legacy behavior: always reply OK and let
|
||||||
// the device ID via offset 41 when the screen sub-conn proceeds.
|
// TOKEN_BITMAPINFO carry the device ID via offset 41 when the screen
|
||||||
|
// sub-conn proceeds.
|
||||||
func (h *MyHandler) handleConnAuth(ctx *connection.Context, data []byte) {
|
func (h *MyHandler) handleConnAuth(ctx *connection.Context, data []byte) {
|
||||||
|
// When the operator supplied a ConnAuth HMAC key (YAMA_CONN_AUTH_KEY),
|
||||||
|
// verify the packet's signature and timestamp before trusting its
|
||||||
|
// clientID. This closes the sub-connection hijack hole that the
|
||||||
|
// source-IP check in hub.BindScreenConn otherwise alone guards against.
|
||||||
|
// A nil key preserves the legacy always-OK behavior.
|
||||||
|
if len(h.connAuthKey) > 0 {
|
||||||
|
if status := h.verifyConnAuth(data); status != protocol.ConnAuthStatusOK {
|
||||||
|
h.rejectConnAuth(ctx, status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pin the parent device's ClientID onto the sub-conn. Without this,
|
// Pin the parent device's ClientID onto the sub-conn. Without this,
|
||||||
// later 1-byte tokens (TOKEN_TERMINAL_START / TOKEN_SHELL_START) have
|
// later 1-byte tokens (TOKEN_TERMINAL_START / TOKEN_SHELL_START) have
|
||||||
// no way to identify which device they belong to — they carry no
|
// no way to identify which device they belong to — they carry no
|
||||||
@@ -188,6 +218,66 @@ func (h *MyHandler) handleConnAuth(ctx *connection.Context, data []byte) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verifyConnAuth checks the ConnAuthPacket's HMAC-SHA256 signature and its
|
||||||
|
// timestamp against the configured shared key. Returns a ConnAuthStatus code
|
||||||
|
// (OK on success).
|
||||||
|
func (h *MyHandler) verifyConnAuth(data []byte) byte {
|
||||||
|
// Need the full [token][clientID][timestamp][nonce][signature] prefix.
|
||||||
|
if len(data) < protocol.ConnAuthOffSignature+protocol.ConnAuthSignatureSize {
|
||||||
|
return protocol.ConnAuthStatusBadSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anti-replay: the client signs its own Unix timestamp, so an old (but
|
||||||
|
// valid) packet cannot be replayed once it leaves the tolerance window.
|
||||||
|
ts := binary.LittleEndian.Uint64(
|
||||||
|
data[protocol.ConnAuthOffTimestamp : protocol.ConnAuthOffTimestamp+8])
|
||||||
|
now := uint64(time.Now().Unix())
|
||||||
|
var skew uint64
|
||||||
|
if now >= ts {
|
||||||
|
skew = now - ts
|
||||||
|
} else {
|
||||||
|
skew = ts - now
|
||||||
|
}
|
||||||
|
if skew > protocol.ConnAuthTimestampToleranceSec {
|
||||||
|
return protocol.ConnAuthStatusClockSkew
|
||||||
|
}
|
||||||
|
|
||||||
|
// HMAC-SHA256 over clientID||timestamp||nonce (offsets 1..33), matching
|
||||||
|
// the client's signMessage("", sigInput, 32). The client stores the hex
|
||||||
|
// encoding of the 32-byte digest in the 64-byte signature field.
|
||||||
|
mac := hmac.New(sha256.New, h.connAuthKey)
|
||||||
|
mac.Write(data[protocol.ConnAuthOffClientID:protocol.ConnAuthOffSignature])
|
||||||
|
want := make([]byte, protocol.ConnAuthSignatureSize)
|
||||||
|
hex.Encode(want, mac.Sum(nil))
|
||||||
|
if !hmac.Equal(want, data[protocol.ConnAuthOffSignature:protocol.ConnAuthOffSignature+protocol.ConnAuthSignatureSize]) {
|
||||||
|
return protocol.ConnAuthStatusBadSignature
|
||||||
|
}
|
||||||
|
return protocol.ConnAuthStatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejectConnAuth replies with a non-OK ConnAuth status and closes the
|
||||||
|
// sub-connection. The client (IOCPClient::TryHandleAuthResponse) treats any
|
||||||
|
// non-zero status as failure and disconnects, so the close here is
|
||||||
|
// belt-and-suspenders for peers that don't.
|
||||||
|
func (h *MyHandler) rejectConnAuth(ctx *connection.Context, status byte) {
|
||||||
|
ack := make([]byte, protocol.ConnAuthAckSize)
|
||||||
|
ack[0] = protocol.TokenConnAuth
|
||||||
|
ack[protocol.ConnAuthAckOffStatus] = status
|
||||||
|
binary.LittleEndian.PutUint64(
|
||||||
|
ack[protocol.ConnAuthAckOffServerTime:protocol.ConnAuthAckOffServerTime+8],
|
||||||
|
uint64(time.Now().Unix()))
|
||||||
|
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.AuditWarn("device.connauth.rejected", map[string]any{
|
||||||
|
"conn": ctx.ID,
|
||||||
|
"ip": ctx.GetPeerIP(),
|
||||||
|
"status": status,
|
||||||
|
"device_id": ctx.GetInfo().ClientID,
|
||||||
|
})
|
||||||
|
_ = ctx.Close()
|
||||||
|
}
|
||||||
|
|
||||||
// deviceIDOfSubConn resolves the parent device of a sub-conn from the
|
// deviceIDOfSubConn resolves the parent device of a sub-conn from the
|
||||||
// ClientID pinned by handleConnAuth. Returns "" for the rare case of a
|
// ClientID pinned by handleConnAuth. Returns "" for the rare case of a
|
||||||
// legacy client that skipped ConnAuth (the Go server's only target is
|
// legacy client that skipped ConnAuth (the Go server's only target is
|
||||||
@@ -218,10 +308,21 @@ func (h *MyHandler) handleTerminalStart(ctx *connection.Context, isPTY bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !h.hub.BindTerminalConn(devID, ctx, isPTY) {
|
if !h.hub.BindTerminalConn(devID, ctx, isPTY) {
|
||||||
// No pending session — this is a stale sub-conn (e.g. browser
|
// Either a stale sub-conn (no pending session — browser gave up and
|
||||||
// gave up and closed term_close already). Drop it.
|
// closed term_close already), or a hijack attempt from a source IP
|
||||||
h.log.Warn("orphan terminal sub-conn: device=%s conn=%d isPTY=%v",
|
// other than the device's login conn. Log the latter distinctly.
|
||||||
devID, ctx.ID, isPTY)
|
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()
|
ctx.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -254,9 +355,21 @@ func (h *MyHandler) handleBitmapInfo(ctx *connection.Context, data []byte) {
|
|||||||
deviceID := strconv.FormatUint(clientID, 10)
|
deviceID := strconv.FormatUint(clientID, 10)
|
||||||
|
|
||||||
if !h.hub.BindScreenConn(deviceID, ctx) {
|
if !h.hub.BindScreenConn(deviceID, ctx) {
|
||||||
// Device not registered — main login hasn't happened (or device just
|
// A failed bind is either a plain orphan (device not registered, or it
|
||||||
// went offline). Drop the orphan sub-conn rather than leak it.
|
// just went offline) or a hijack attempt — a sub-conn claiming a
|
||||||
h.log.Warn("orphan screen sub-conn %d for unknown device %s; closing", ctx.ID, deviceID)
|
// 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()
|
ctx.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -337,6 +450,18 @@ func (h *MyHandler) handleScreenFrame(ctx *connection.Context, data []byte, alwa
|
|||||||
h.hub.PublishScreenFrame(deviceID, packet, isKey)
|
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)
|
// handleLogin handles client login (TOKEN_LOGIN = 102)
|
||||||
func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
||||||
info, err := protocol.ParseLoginInfo(data)
|
info, err := protocol.ParseLoginInfo(data)
|
||||||
@@ -504,7 +629,11 @@ func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) {
|
|||||||
// definitive answer instead of hanging, while a brute-forcer is capped.
|
// definitive answer instead of hanging, while a brute-forcer is capped.
|
||||||
ip := ctx.GetPeerIP()
|
ip := ctx.GetPeerIP()
|
||||||
if !h.loginLimit.Allow(ip) {
|
if !h.loginLimit.Allow(ip) {
|
||||||
h.log.Warn("TOKEN_AUTH rate-limited: ip=%s conn=%d", ip, ctx.ID)
|
h.log.AuditWarn("device.auth.throttled", map[string]any{
|
||||||
|
"conn": ctx.ID,
|
||||||
|
"ip": ip,
|
||||||
|
"reason": "rate_limited",
|
||||||
|
})
|
||||||
resp := h.auth.BuildResponse(&auth.AuthResult{Valid: false, Message: "尝试过于频繁,请稍后再试"})
|
resp := h.auth.BuildResponse(&auth.AuthResult{Valid: false, Message: "尝试过于频繁,请稍后再试"})
|
||||||
if err := h.srv.Send(ctx, resp); err != nil {
|
if err := h.srv.Send(ctx, resp); err != nil {
|
||||||
h.log.Error("Failed to send rate-limit auth response to client %d: %v", ctx.ID, err)
|
h.log.Error("Failed to send rate-limit auth response to client %d: %v", ctx.ID, err)
|
||||||
@@ -520,12 +649,23 @@ func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) {
|
|||||||
authType = "V2"
|
authType = "V2"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device auth records are audit events (not just debug logs): a flood of
|
||||||
|
// failures against one address is how passcode guessing looks. The
|
||||||
|
// submitted passcode is NEVER logged — only its presence and length.
|
||||||
|
fields := map[string]any{
|
||||||
|
"conn": ctx.ID,
|
||||||
|
"ip": ip,
|
||||||
|
"auth_version": authType,
|
||||||
|
"device_id": info.ClientID,
|
||||||
|
"computer": info.ComputerName,
|
||||||
|
"sn": result.SN,
|
||||||
|
"passcode_len": len(result.Passcode),
|
||||||
|
"has_passcode": result.Passcode != "",
|
||||||
|
}
|
||||||
if result.Valid {
|
if result.Valid {
|
||||||
h.log.Info("Auth %s success: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
|
h.log.Audit("device.auth.success", fields)
|
||||||
authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode)
|
|
||||||
} else {
|
} else {
|
||||||
h.log.Warn("Auth %s failed: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
|
h.log.AuditWarn("device.auth.failure", fields)
|
||||||
authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build and send response
|
// Build and send response
|
||||||
@@ -597,15 +737,22 @@ func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) {
|
|||||||
if !ctx.IsAuthorized.Load() {
|
if !ctx.IsAuthorized.Load() {
|
||||||
ctx.IsAuthorized.Store(true)
|
ctx.IsAuthorized.Store(true)
|
||||||
info := ctx.GetInfo()
|
info := ctx.GetInfo()
|
||||||
|
version := "V1"
|
||||||
if authResult.IsV2 {
|
if authResult.IsV2 {
|
||||||
// V2 authorization
|
version = "V2"
|
||||||
h.log.Info("Heartbeat auth V2 success: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
|
|
||||||
info.ClientID, info.ComputerName, ctx.GetPeerIP(), authResult.SN, authResult.Passcode)
|
|
||||||
} else {
|
|
||||||
// V1 authorization
|
|
||||||
h.log.Info("Heartbeat auth V1 success: clientID=%s computer=%s ip=%s sn=%s passcode=%s pwdHmac=%d",
|
|
||||||
info.ClientID, info.ComputerName, ctx.GetPeerIP(), authResult.SN, authResult.Passcode, authResult.PwdHmac)
|
|
||||||
}
|
}
|
||||||
|
// Same redaction rule as TOKEN_AUTH: never log the passcode
|
||||||
|
// or the password HMAC, only that they were supplied.
|
||||||
|
h.log.Audit("device.auth.heartbeat.success", map[string]any{
|
||||||
|
"conn": ctx.ID,
|
||||||
|
"ip": ctx.GetPeerIP(),
|
||||||
|
"auth_version": version,
|
||||||
|
"device_id": info.ClientID,
|
||||||
|
"computer": info.ComputerName,
|
||||||
|
"sn": authResult.SN,
|
||||||
|
"passcode_len": len(authResult.Passcode),
|
||||||
|
"has_pwd_hmac": authResult.PwdHmac != 0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -792,8 +939,30 @@ func main() {
|
|||||||
logCfg.MaxAge = 30 // 30 days
|
logCfg.MaxAge = 30 // 30 days
|
||||||
logCfg.Compress = true
|
logCfg.Compress = true
|
||||||
|
|
||||||
|
// Security audit trail: a separate JSON-lines file so login attempts,
|
||||||
|
// authz denials, remote-control sessions and admin mutations are not
|
||||||
|
// buried in debug output and can be retained / shipped independently.
|
||||||
|
// YAMA_AUDIT_LOG overrides the path; "off"/"none"/"0"/"-" disables the
|
||||||
|
// separate file (records still reach server.log).
|
||||||
|
auditFile := strings.TrimSpace(os.Getenv("YAMA_AUDIT_LOG"))
|
||||||
|
if auditFile == "" {
|
||||||
|
auditFile = "logs/audit.log"
|
||||||
|
}
|
||||||
|
switch strings.ToLower(auditFile) {
|
||||||
|
case "off", "none", "0", "-":
|
||||||
|
auditFile = ""
|
||||||
|
}
|
||||||
|
logCfg.AuditFile = auditFile
|
||||||
|
logCfg.AuditMaxSize = 100
|
||||||
|
logCfg.AuditMaxBackups = 20
|
||||||
|
logCfg.AuditMaxAge = 90
|
||||||
|
logCfg.AuditCompress = true
|
||||||
|
|
||||||
log := logger.New(logCfg)
|
log := logger.New(logCfg)
|
||||||
log.Info("====== Copyright (c) 2026 simpleremoter.com. All rights resvered. ======")
|
log.Info("====== Copyright (c) 2026 simpleremoter.com. All rights resvered. ======")
|
||||||
|
if auditFile != "" {
|
||||||
|
log.Info("Security audit log: %s", auditFile)
|
||||||
|
}
|
||||||
|
|
||||||
// Track env vars where we fell back to a built-in default. Printed once
|
// Track env vars where we fell back to a built-in default. Printed once
|
||||||
// at the end of startup so the operator sees what's in effect — vars the
|
// at the end of startup so the operator sees what's in effect — vars the
|
||||||
@@ -822,6 +991,34 @@ func main() {
|
|||||||
// the HTTP server reads from it.
|
// the HTTP server reads from it.
|
||||||
deviceHub := hub.New()
|
deviceHub := hub.New()
|
||||||
|
|
||||||
|
// Persistent device history: every device that has ever logged in, so the
|
||||||
|
// web UI can keep showing it after it goes offline. Same working-directory
|
||||||
|
// convention as users.json. Retention is capped by count (newest last_seen
|
||||||
|
// wins) so the file and the device_list payload stay bounded.
|
||||||
|
devicesFile := os.Getenv("YAMA_DEVICES_FILE")
|
||||||
|
if devicesFile == "" {
|
||||||
|
devicesFile = "devices.json"
|
||||||
|
rememberDefault("YAMA_DEVICES_FILE", devicesFile)
|
||||||
|
}
|
||||||
|
devicesMax := 1000
|
||||||
|
if v := os.Getenv("YAMA_DEVICES_MAX"); v != "" {
|
||||||
|
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
|
||||||
|
devicesMax = n
|
||||||
|
} else {
|
||||||
|
log.Warn("Invalid YAMA_DEVICES_MAX=%q (want a positive integer); using %d", v, devicesMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deviceHistory, histErr := devicehistory.Open(devicesFile, devicesMax,
|
||||||
|
log.WithPrefix("History"))
|
||||||
|
if histErr != nil {
|
||||||
|
// Keep the store: live recording still works, and the next flush
|
||||||
|
// replaces the unreadable file. Losing old history is preferable to
|
||||||
|
// refusing to start the server.
|
||||||
|
log.Warn("Failed to load %s: %v (starting with empty history)", devicesFile, histErr)
|
||||||
|
}
|
||||||
|
deviceHub.Subscribe(deviceHistory)
|
||||||
|
log.Info("Device history: %s (max %d)", devicesFile, devicesMax)
|
||||||
|
|
||||||
// Build the CMD_MASTERSETTING signer based on env vars:
|
// Build the CMD_MASTERSETTING signer based on env vars:
|
||||||
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
|
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
|
||||||
// HMAC master key lives here)
|
// HMAC master key lives here)
|
||||||
@@ -920,6 +1117,31 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Optional one-way TLS on the device port. Both vars must be set to enable;
|
||||||
|
// a cert without its key (or vice versa) is treated as disabled with a
|
||||||
|
// warning. The cert is loaded (and errors surfaced) inside Server.Start().
|
||||||
|
tlsCert := os.Getenv("YAMA_TLS_CERT")
|
||||||
|
tlsKey := os.Getenv("YAMA_TLS_KEY")
|
||||||
|
if tlsCert != "" && tlsKey != "" {
|
||||||
|
log.Info("TLS enabled for device port (one-way): cert=%s", tlsCert)
|
||||||
|
} else if tlsCert != "" || tlsKey != "" {
|
||||||
|
log.Warn("TLS disabled: both YAMA_TLS_CERT and YAMA_TLS_KEY must be set")
|
||||||
|
} else {
|
||||||
|
log.Info("TLS disabled (YAMA_TLS_CERT/YAMA_TLS_KEY not set) — device port is plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional ConnAuth HMAC verification for sub-connections (screen/terminal/
|
||||||
|
// file). The shared key must match what the client signs with — either the
|
||||||
|
// client's built-in HMAC key (see client license.cpp / license_unix.cpp) or
|
||||||
|
// a custom key once the client is taught to read one. Unset means legacy
|
||||||
|
// behavior: accept any sub-connection and rely on the source-IP check in
|
||||||
|
// hub.BindScreenConn alone.
|
||||||
|
var connAuthKey []byte
|
||||||
|
if v := strings.TrimSpace(os.Getenv("YAMA_CONN_AUTH_KEY")); v != "" {
|
||||||
|
connAuthKey = []byte(v)
|
||||||
|
log.Info("ConnAuth HMAC verification enabled for device sub-connections (YAMA_CONN_AUTH_KEY)")
|
||||||
|
}
|
||||||
|
|
||||||
// Create servers for each port
|
// Create servers for each port
|
||||||
var servers []*server.Server
|
var servers []*server.Server
|
||||||
for _, port := range ports {
|
for _, port := range ports {
|
||||||
@@ -928,18 +1150,21 @@ func main() {
|
|||||||
config.MaxConnections = 9999
|
config.MaxConnections = 9999
|
||||||
config.PerIPMaxConnections = envInt("YAMA_PERIP_MAX_CONNS", config.PerIPMaxConnections)
|
config.PerIPMaxConnections = envInt("YAMA_PERIP_MAX_CONNS", config.PerIPMaxConnections)
|
||||||
config.PerIPConnRateLimit = envInt("YAMA_PERIP_CONN_RATE", config.PerIPConnRateLimit)
|
config.PerIPConnRateLimit = envInt("YAMA_PERIP_CONN_RATE", config.PerIPConnRateLimit)
|
||||||
|
config.TLSCertFile = tlsCert
|
||||||
|
config.TLSKeyFile = tlsKey
|
||||||
|
|
||||||
srv := server.New(config)
|
srv := server.New(config)
|
||||||
srv.SetLogger(log.WithPrefix(fmt.Sprintf("Server:%d", port)))
|
srv.SetLogger(log.WithPrefix(fmt.Sprintf("Server:%d", port)))
|
||||||
|
|
||||||
// Create handler for this server
|
// Create handler for this server
|
||||||
handler := &MyHandler{
|
handler := &MyHandler{
|
||||||
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
|
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
|
||||||
auth: authenticator,
|
auth: authenticator,
|
||||||
srv: srv,
|
srv: srv,
|
||||||
hub: deviceHub,
|
hub: deviceHub,
|
||||||
signer: signer,
|
signer: signer,
|
||||||
loginLimit: authLoginLimit,
|
loginLimit: authLoginLimit,
|
||||||
|
connAuthKey: connAuthKey,
|
||||||
}
|
}
|
||||||
srv.SetHandler(handler)
|
srv.SetHandler(handler)
|
||||||
|
|
||||||
@@ -983,6 +1208,14 @@ func main() {
|
|||||||
if basePath != "" {
|
if basePath != "" {
|
||||||
log.Info("Serving web UI under base path %q (subpath deployment)", basePath)
|
log.Info("Serving web UI under base path %q (subpath deployment)", basePath)
|
||||||
}
|
}
|
||||||
|
// White-label title: when the web UI is mounted under another domain
|
||||||
|
// (basePath != ""), replace the SimpleRemoter branding with this name.
|
||||||
|
// YAMA_WEB_BRAND_NAME is human-editable; empty falls back to the base
|
||||||
|
// path's last segment (e.g. "/remoter" → "Remoter").
|
||||||
|
brandName := strings.TrimSpace(os.Getenv("YAMA_WEB_BRAND_NAME"))
|
||||||
|
if brandName != "" {
|
||||||
|
log.Info("White-label web UI title %q (YAMA_WEB_BRAND_NAME)", brandName)
|
||||||
|
}
|
||||||
// SSO: an optional detector bridges an already-authenticated portal
|
// SSO: an optional detector bridges an already-authenticated portal
|
||||||
// session into a YAMA web session via /sso — but only for portal
|
// session into a YAMA web session via /sso — but only for portal
|
||||||
// administrators. Any other portal role (viewer, unknown) still has to
|
// administrators. Any other portal role (viewer, unknown) still has to
|
||||||
@@ -1002,12 +1235,14 @@ func main() {
|
|||||||
LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute),
|
LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute),
|
||||||
TrustForwardedFor: trustProxy,
|
TrustForwardedFor: trustProxy,
|
||||||
BasePath: basePath,
|
BasePath: basePath,
|
||||||
|
BrandName: brandName,
|
||||||
SSO: ssoDetector,
|
SSO: ssoDetector,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start HTTP server for web UI. Hub gives it read-only access to the
|
// Start HTTP server for web UI. Hub gives it read-only access to the
|
||||||
// device registry; the authenticator owns user accounts and session tokens.
|
// device registry; the authenticator owns user accounts and session tokens.
|
||||||
httpSrv := web.New(*httpPort, log.WithPrefix("Web"), deviceHub, webAuth).
|
httpSrv := web.New(*httpPort, log.WithPrefix("Web"), deviceHub, webAuth).
|
||||||
|
WithDeviceHistory(deviceHistory).
|
||||||
WithConfig(webCfg)
|
WithConfig(webCfg)
|
||||||
if err := httpSrv.Start(); err != nil {
|
if err := httpSrv.Start(); err != nil {
|
||||||
log.Fatal("Failed to start HTTP server: %v", err)
|
log.Fatal("Failed to start HTTP server: %v", err)
|
||||||
@@ -1081,5 +1316,20 @@ func main() {
|
|||||||
for _, srv := range servers {
|
for _, srv := range servers {
|
||||||
srv.Stop()
|
srv.Stop()
|
||||||
}
|
}
|
||||||
|
// Devices still connected at shutdown never fire OnDeviceOffline (their
|
||||||
|
// TCP handlers are stopped above), so stamp them now. Otherwise a device
|
||||||
|
// that had been online for days would show a stale "last seen" after the
|
||||||
|
// restart until it reconnects.
|
||||||
|
for _, d := range deviceHub.ListDevices() {
|
||||||
|
deviceHistory.OnDeviceOffline(d.ID)
|
||||||
|
}
|
||||||
|
// Last-chance flush: the debounce timer may hold the final offline
|
||||||
|
// timestamps from devices that dropped while we were shutting down.
|
||||||
|
if err := deviceHistory.Close(); err != nil {
|
||||||
|
log.Warn("device history flush: %v", err)
|
||||||
|
}
|
||||||
log.Info("Server stopped")
|
log.Info("Server stopped")
|
||||||
|
// Flush and release the rotated log handles. Without this the last audit
|
||||||
|
// records can sit in lumberjack's buffered writer on shutdown.
|
||||||
|
_ = log.Close()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,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,103 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// connAuthTestKey is an arbitrary non-secret key used only to exercise the
|
||||||
|
// verifyConnAuth code path in tests. The production key is never compiled in:
|
||||||
|
// it is read from the YAMA_CONN_AUTH_KEY environment variable at startup.
|
||||||
|
const connAuthTestKey = "test-conn-auth-key"
|
||||||
|
|
||||||
|
// signConnAuth reproduces the client's ConnAuth signature: hex(HMAC-SHA256(key,
|
||||||
|
// clientID||timestamp||nonce)) as 64 lowercase hex chars. Mirrors
|
||||||
|
// client/IOCPClient.cpp PerformConnAuth + license.cpp signMessage.
|
||||||
|
func signConnAuth(key string, clientID, ts uint64, nonce [16]byte) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(key))
|
||||||
|
var msg [32]byte
|
||||||
|
binary.LittleEndian.PutUint64(msg[0:8], clientID)
|
||||||
|
binary.LittleEndian.PutUint64(msg[8:16], ts)
|
||||||
|
copy(msg[16:32], nonce[:])
|
||||||
|
mac.Write(msg[:])
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNonce() [16]byte {
|
||||||
|
var n [16]byte
|
||||||
|
for i := range n {
|
||||||
|
n[i] = byte(i)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnAuthHmacAlgorithmVector pins the signing algorithm and byte layout
|
||||||
|
// (HMAC-SHA256 over clientID||timestamp||nonce, lowercase hex) against a vector
|
||||||
|
// computed independently with Python's hmac/hashlib (not Go's crypto). The key
|
||||||
|
// value is irrelevant here — what matters is that the server reproduces the
|
||||||
|
// exact signing the client performs. The real key is supplied at runtime via
|
||||||
|
// YAMA_CONN_AUTH_KEY, never compiled in.
|
||||||
|
func TestConnAuthHmacAlgorithmVector(t *testing.T) {
|
||||||
|
const want = "e59bf1259c77e065afa604cb5b1794b0c8c03fbb7e3473f5c3c34c2b94a90406"
|
||||||
|
if got := signConnAuth(connAuthTestKey, 0x1122334455667788, 1700000000, testNonce()); got != want {
|
||||||
|
t.Fatalf("HMAC mismatch:\n got %s\n want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyConnAuth(t *testing.T) {
|
||||||
|
h := &MyHandler{connAuthKey: []byte(connAuthTestKey)}
|
||||||
|
|
||||||
|
build := func(ts uint64, sig []byte) []byte {
|
||||||
|
pkt := make([]byte, protocol.ConnAuthPacketSize)
|
||||||
|
pkt[0] = protocol.TokenConnAuth
|
||||||
|
binary.LittleEndian.PutUint64(pkt[protocol.ConnAuthOffClientID:], 0x1122334455667788)
|
||||||
|
binary.LittleEndian.PutUint64(pkt[protocol.ConnAuthOffTimestamp:], ts)
|
||||||
|
for i := range 16 {
|
||||||
|
pkt[protocol.ConnAuthOffNonce+i] = byte(i)
|
||||||
|
}
|
||||||
|
copy(pkt[protocol.ConnAuthOffSignature:], sig)
|
||||||
|
return pkt
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := testNonce()
|
||||||
|
|
||||||
|
t.Run("ok", func(t *testing.T) {
|
||||||
|
ts := uint64(time.Now().Unix())
|
||||||
|
sig := signConnAuth(connAuthTestKey, 0x1122334455667788, ts, nonce)
|
||||||
|
if got := h.verifyConnAuth(build(ts, []byte(sig))); got != protocol.ConnAuthStatusOK {
|
||||||
|
t.Fatalf("expected OK, got status %d", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bad_signature", func(t *testing.T) {
|
||||||
|
ts := uint64(time.Now().Unix())
|
||||||
|
sig := signConnAuth(connAuthTestKey, 0x1122334455667788, ts, nonce)
|
||||||
|
pkt := build(ts, []byte(sig))
|
||||||
|
pkt[protocol.ConnAuthOffSignature] ^= 0xff
|
||||||
|
if got := h.verifyConnAuth(pkt); got != protocol.ConnAuthStatusBadSignature {
|
||||||
|
t.Fatalf("expected BadSignature, got %d", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("clock_skew", func(t *testing.T) {
|
||||||
|
// Just outside the 300 s tolerance, with a signature valid for that
|
||||||
|
// timestamp, so the skew check fires before the signature check.
|
||||||
|
ts := uint64(time.Now().Unix()) - protocol.ConnAuthTimestampToleranceSec - 1
|
||||||
|
sig := signConnAuth(connAuthTestKey, 0x1122334455667788, ts, nonce)
|
||||||
|
if got := h.verifyConnAuth(build(ts, []byte(sig))); got != protocol.ConnAuthStatusClockSkew {
|
||||||
|
t.Fatalf("expected ClockSkew, got %d", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bad_size", func(t *testing.T) {
|
||||||
|
if got := h.verifyConnAuth([]byte{protocol.TokenConnAuth}); got != protocol.ConnAuthStatusBadSize {
|
||||||
|
t.Fatalf("expected BadSize, got %d", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
// Package devicehistory persists the set of devices that have ever logged in
|
||||||
|
// to the Go server, so the web UI can keep listing them after they go offline.
|
||||||
|
//
|
||||||
|
// It implements hub.EventHandler and is subscribed to the hub, which means the
|
||||||
|
// only wiring needed at startup is hub.Subscribe(store). Online events upsert
|
||||||
|
// the record (refreshing the display fields the client reports at login) and
|
||||||
|
// offline events merely refresh last_seen.
|
||||||
|
//
|
||||||
|
// Storage is a small JSON file, mirroring the wsauth users.json convention:
|
||||||
|
// atomic temp-file + fsync + rename, loaded once at startup and written back
|
||||||
|
// on a short debounce so a burst of reconnects doesn't rewrite it per device.
|
||||||
|
package devicehistory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// fileVersion lets a future release migrate the on-disk shape. Unknown
|
||||||
|
// versions are still parsed best-effort; only structurally invalid JSON
|
||||||
|
// makes Open fail.
|
||||||
|
fileVersion = 1
|
||||||
|
|
||||||
|
// flushDelay coalesces updates. Device logins/disconnects are sparse, but
|
||||||
|
// a server restart makes every agent reconnect at once; without the
|
||||||
|
// debounce that would be one file write per device.
|
||||||
|
flushDelay = 500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// Record is one persisted device. The fields mirror hub.DeviceInfo so the
|
||||||
|
// offline list can be rendered with the same front-end card code; only the
|
||||||
|
// fields that are stable across sessions are kept (RTT / active window are
|
||||||
|
// live-only and would be stale noise on an offline card).
|
||||||
|
type Record struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Capability string `json:"capability,omitempty"`
|
||||||
|
OS string `json:"os,omitempty"`
|
||||||
|
CPU string `json:"cpu,omitempty"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
InstallTime string `json:"install_time,omitempty"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
PeerIP string `json:"peer_ip,omitempty"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
Screen string `json:"screen,omitempty"`
|
||||||
|
|
||||||
|
FirstSeen int64 `json:"first_seen"`
|
||||||
|
LastSeen int64 `json:"last_seen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileShape struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Devices map[string]*Record `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store is the in-memory device history plus its backing file. Safe for
|
||||||
|
// concurrent use.
|
||||||
|
type Store struct {
|
||||||
|
path string
|
||||||
|
max int // 0 = unlimited
|
||||||
|
log *logger.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
devices map[string]*Record
|
||||||
|
// online is derived from hub events and used only by pruning: a device
|
||||||
|
// that is still connected must keep its record even if its last login is
|
||||||
|
// older than the 1000th offline device's. Not persisted — after a restart
|
||||||
|
// every device is offline until it reconnects.
|
||||||
|
online map[string]bool
|
||||||
|
dirty bool
|
||||||
|
timer *time.Timer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open loads the history at path. A missing file is an empty history, not an
|
||||||
|
// error. If the file exists but cannot be parsed, the returned store is still
|
||||||
|
// usable (empty) along with the error, so callers can log and continue — the
|
||||||
|
// next flush will replace the corrupt file.
|
||||||
|
func Open(path string, max int, log *logger.Logger) (*Store, error) {
|
||||||
|
s := newStore(path, max, log)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
var f fileShape
|
||||||
|
if err := json.Unmarshal(data, &f); err != nil {
|
||||||
|
return s, fmt.Errorf("parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
for id, r := range f.Devices {
|
||||||
|
if r == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The map key is authoritative; tolerate records written by a
|
||||||
|
// future/older shape that omitted or disagreed with the id field.
|
||||||
|
r.ID = id
|
||||||
|
s.devices[id] = r
|
||||||
|
}
|
||||||
|
s.pruneLocked()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns an empty store for path without touching disk. Useful when Open
|
||||||
|
// failed and the caller still wants live recording to work.
|
||||||
|
func New(path string, max int, log *logger.Logger) *Store {
|
||||||
|
return newStore(path, max, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStore(path string, max int, log *logger.Logger) *Store {
|
||||||
|
return &Store{
|
||||||
|
path: path,
|
||||||
|
max: max,
|
||||||
|
log: log,
|
||||||
|
devices: make(map[string]*Record),
|
||||||
|
online: make(map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnDeviceOnline records a device that just logged in. It refreshes every
|
||||||
|
// display field because the agent reports its current name/group/version at
|
||||||
|
// each login, and preserves first_seen across reconnects.
|
||||||
|
func (s *Store) OnDeviceOnline(d hub.DeviceInfo) {
|
||||||
|
s.upsertAt(d, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) upsertAt(d hub.DeviceInfo, at time.Time) {
|
||||||
|
if d.ID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := at.Unix()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
rec, ok := s.devices[d.ID]
|
||||||
|
if !ok {
|
||||||
|
rec = &Record{ID: d.ID, FirstSeen: now}
|
||||||
|
s.devices[d.ID] = rec
|
||||||
|
}
|
||||||
|
if rec.FirstSeen == 0 {
|
||||||
|
rec.FirstSeen = now
|
||||||
|
}
|
||||||
|
rec.Name = d.Name
|
||||||
|
rec.Group = d.Group
|
||||||
|
rec.Version = d.Version
|
||||||
|
rec.Capability = d.Capability
|
||||||
|
rec.OS = d.OS
|
||||||
|
rec.CPU = d.CPU
|
||||||
|
rec.FilePath = d.FilePath
|
||||||
|
rec.InstallTime = d.InstallTime
|
||||||
|
rec.Location = d.Location
|
||||||
|
rec.PeerIP = d.PeerIP
|
||||||
|
rec.IP = d.IP
|
||||||
|
rec.Screen = d.Screen
|
||||||
|
rec.LastSeen = now
|
||||||
|
s.online[d.ID] = true
|
||||||
|
|
||||||
|
s.pruneLocked()
|
||||||
|
s.markDirtyLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnDeviceOffline refreshes last_seen. The record was created when the device
|
||||||
|
// logged in; an unknown ID is ignored (e.g. a device rejected by signing never
|
||||||
|
// reaches the hub).
|
||||||
|
func (s *Store) OnDeviceOffline(id string) {
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.offlineAt(id, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) offlineAt(id string, at time.Time) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.online, id)
|
||||||
|
if rec, ok := s.devices[id]; ok {
|
||||||
|
rec.LastSeen = at.Unix()
|
||||||
|
s.markDirtyLocked()
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The remaining EventHandler methods are not relevant to device history.
|
||||||
|
func (s *Store) OnDeviceUpdate(string, int, string) {}
|
||||||
|
func (s *Store) OnScreenFrame(string, []byte, bool) {}
|
||||||
|
func (s *Store) OnResolutionChange(string, int, int) {}
|
||||||
|
func (s *Store) OnCursorChange(string, byte) {}
|
||||||
|
func (s *Store) OnClipboardText(string, string) {}
|
||||||
|
func (s *Store) OnTerminalReady(string, bool) {}
|
||||||
|
func (s *Store) OnTerminalData(string, []byte) {}
|
||||||
|
func (s *Store) OnTerminalClosed(string, string) {}
|
||||||
|
|
||||||
|
// Offline returns the records whose IDs are not in online, newest last_seen
|
||||||
|
// first, converted to the hub's device payload shape with Online=false.
|
||||||
|
//
|
||||||
|
// Callers pass the hub's current online snapshot; the store deliberately does
|
||||||
|
// not know which device is online, so a reconnect that re-registers the same
|
||||||
|
// ID can never produce a duplicate row.
|
||||||
|
func (s *Store) Offline(online map[string]struct{}) []hub.DeviceInfo {
|
||||||
|
s.mu.Lock()
|
||||||
|
out := make([]hub.DeviceInfo, 0, len(s.devices))
|
||||||
|
for id, rec := range s.devices {
|
||||||
|
if _, ok := online[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, hub.DeviceInfo{
|
||||||
|
ID: rec.ID,
|
||||||
|
Name: rec.Name,
|
||||||
|
Group: rec.Group,
|
||||||
|
Version: rec.Version,
|
||||||
|
Capability: rec.Capability,
|
||||||
|
OS: rec.OS,
|
||||||
|
CPU: rec.CPU,
|
||||||
|
FilePath: rec.FilePath,
|
||||||
|
InstallTime: rec.InstallTime,
|
||||||
|
Location: rec.Location,
|
||||||
|
IP: rec.IP,
|
||||||
|
PeerIP: rec.PeerIP,
|
||||||
|
Screen: rec.Screen,
|
||||||
|
LastSeen: rec.LastSeen,
|
||||||
|
Online: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].LastSeen != out[j].LastSeen {
|
||||||
|
return out[i].LastSeen > out[j].LastSeen
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deletes the persisted record for id and reports whether one existed.
|
||||||
|
// This is the web UI's explicit "delete" for an offline device: an offline
|
||||||
|
// record has no live agent to uninstall, so forgetting it is all "delete" can
|
||||||
|
// mean. The caller decides when removal is appropriate; the store does not
|
||||||
|
// refuse to drop a record that happens to be marked online.
|
||||||
|
func (s *Store) Remove(id string) bool {
|
||||||
|
if id == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
_, existed := s.devices[id]
|
||||||
|
if existed {
|
||||||
|
delete(s.devices, id)
|
||||||
|
delete(s.online, id)
|
||||||
|
s.markDirtyLocked()
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return existed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush writes the current state synchronously. Normally the debounced timer
|
||||||
|
// does this; Flush exists for tests and graceful shutdown.
|
||||||
|
func (s *Store) Flush() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.timer != nil {
|
||||||
|
s.timer.Stop()
|
||||||
|
s.timer = nil
|
||||||
|
}
|
||||||
|
if !s.dirty {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.dirty = false
|
||||||
|
data, err := s.marshalLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeAtomic(s.path, data); err != nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.dirty = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the debounce timer and flushes pending changes.
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
return s.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) markDirtyLocked() {
|
||||||
|
s.dirty = true
|
||||||
|
if s.timer == nil {
|
||||||
|
s.timer = time.AfterFunc(flushDelay, s.flush)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) flush() {
|
||||||
|
if err := s.Flush(); err != nil && s.log != nil {
|
||||||
|
s.log.Error("Failed to persist device history to %s: %v", s.path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) marshalLocked() ([]byte, error) {
|
||||||
|
f := fileShape{Version: fileVersion, Devices: s.devices}
|
||||||
|
return json.MarshalIndent(f, "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneLocked keeps the max most-recently-seen records. It runs after every
|
||||||
|
// online upsert, which is the only path that can grow the map; the O(n log n)
|
||||||
|
// sort only happens when the cap is actually exceeded.
|
||||||
|
func (s *Store) pruneLocked() {
|
||||||
|
if s.max <= 0 || len(s.devices) <= s.max {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(s.devices))
|
||||||
|
for id := range s.devices {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
sort.Slice(ids, func(i, j int) bool {
|
||||||
|
oi, oj := s.online[ids[i]], s.online[ids[j]]
|
||||||
|
if oi != oj {
|
||||||
|
return !oi // evict offline records before online ones
|
||||||
|
}
|
||||||
|
return s.devices[ids[i]].LastSeen < s.devices[ids[j]].LastSeen
|
||||||
|
})
|
||||||
|
for _, id := range ids[:len(ids)-s.max] {
|
||||||
|
delete(s.devices, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAtomic mirrors wsauth.writeUsersFile: temp file in the same directory,
|
||||||
|
// fsync, then rename over the destination.
|
||||||
|
func writeAtomic(path string, data []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if dir != "" {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(dir, "devices-*.json.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package devicehistory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testDevice(id, name string) hub.DeviceInfo {
|
||||||
|
return hub.DeviceInfo{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Group: "office",
|
||||||
|
Version: "1.2.3",
|
||||||
|
OS: "Windows 11",
|
||||||
|
IP: "203.0.113.7",
|
||||||
|
PeerIP: "198.51.100.9",
|
||||||
|
Screen: "1:1920*1080",
|
||||||
|
Online: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreRecordsLifecycleViaHub(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
s, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
h := hub.New()
|
||||||
|
h.Subscribe(s)
|
||||||
|
|
||||||
|
h.Register(&hub.Device{ID: "dev-1", Name: "PC-1", Group: "office"},
|
||||||
|
&connection.Context{RemoteAddr: "198.51.100.9:5000"})
|
||||||
|
|
||||||
|
// The device is online, so it must not appear in the offline list.
|
||||||
|
if got := s.Offline(map[string]struct{}{"dev-1": {}}); len(got) != 0 {
|
||||||
|
t.Fatalf("offline while online = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Unregister("dev-1")
|
||||||
|
|
||||||
|
off := s.Offline(nil)
|
||||||
|
if len(off) != 1 {
|
||||||
|
t.Fatalf("offline entries = %d, want 1", len(off))
|
||||||
|
}
|
||||||
|
d := off[0]
|
||||||
|
if d.ID != "dev-1" || d.Name != "PC-1" || d.Group != "office" {
|
||||||
|
t.Fatalf("offline device = %+v", d)
|
||||||
|
}
|
||||||
|
if d.Online {
|
||||||
|
t.Fatal("offline entry marked online")
|
||||||
|
}
|
||||||
|
if d.LastSeen == 0 {
|
||||||
|
t.Fatal("last_seen not set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorePersistsAndPreservesFirstSeen(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
s, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := time.Unix(1_700_000_000, 0)
|
||||||
|
s.upsertAt(testDevice("dev-1", "old-name"), first)
|
||||||
|
// A later login refreshes display fields and last_seen but keeps first_seen.
|
||||||
|
s.upsertAt(testDevice("dev-1", "new-name"), first.Add(time.Hour))
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Fatalf("Flush: %v", err)
|
||||||
|
}
|
||||||
|
_ = s.Close()
|
||||||
|
|
||||||
|
reopened, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = reopened.Close() })
|
||||||
|
|
||||||
|
reopened.mu.Lock()
|
||||||
|
rec := reopened.devices["dev-1"]
|
||||||
|
reopened.mu.Unlock()
|
||||||
|
if rec == nil {
|
||||||
|
t.Fatal("record not persisted")
|
||||||
|
}
|
||||||
|
if rec.Name != "new-name" {
|
||||||
|
t.Fatalf("name = %q, want new-name", rec.Name)
|
||||||
|
}
|
||||||
|
if rec.FirstSeen != first.Unix() {
|
||||||
|
t.Fatalf("first_seen = %d, want %d", rec.FirstSeen, first.Unix())
|
||||||
|
}
|
||||||
|
if rec.LastSeen != first.Add(time.Hour).Unix() {
|
||||||
|
t.Fatalf("last_seen = %d, want %d", rec.LastSeen, first.Add(time.Hour).Unix())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorePrunesOldest(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
s, err := Open(path, 2, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
base := time.Unix(1_700_000_000, 0)
|
||||||
|
s.upsertAt(testDevice("a", "A"), base)
|
||||||
|
s.upsertAt(testDevice("b", "B"), base.Add(time.Minute))
|
||||||
|
s.upsertAt(testDevice("c", "C"), base.Add(2*time.Minute))
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
_, hasA := s.devices["a"]
|
||||||
|
_, hasC := s.devices["c"]
|
||||||
|
n := len(s.devices)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if n != 2 || hasA || !hasC {
|
||||||
|
t.Fatalf("after prune: n=%d hasA=%v hasC=%v, want 2/false/true", n, hasA, hasC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneEvictsOfflineBeforeOnline(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
s, err := Open(path, 2, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
base := time.Unix(1_700_000_000, 0)
|
||||||
|
s.upsertAt(testDevice("online-old", "Old"), base)
|
||||||
|
s.upsertAt(testDevice("offline-new", "Gone"), base.Add(time.Minute))
|
||||||
|
s.offlineAt("offline-new", base.Add(2*time.Minute))
|
||||||
|
// This upsert exceeds the cap; the offline record must be evicted before
|
||||||
|
// the still-connected device, even though its last_seen is newer.
|
||||||
|
s.upsertAt(testDevice("online-2", "New"), base.Add(3*time.Minute))
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
_, hasOld := s.devices["online-old"]
|
||||||
|
_, hasGone := s.devices["offline-new"]
|
||||||
|
_, hasNew := s.devices["online-2"]
|
||||||
|
n := len(s.devices)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if n != 2 || !hasOld || hasGone || !hasNew {
|
||||||
|
t.Fatalf("after prune: n=%d hasOld=%v hasGone=%v hasNew=%v", n, hasOld, hasGone, hasNew)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOfflineSortedNewestFirst(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
s, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
base := time.Unix(1_700_000_000, 0)
|
||||||
|
s.upsertAt(testDevice("old", "Old"), base)
|
||||||
|
s.upsertAt(testDevice("new", "New"), base.Add(time.Hour))
|
||||||
|
|
||||||
|
off := s.Offline(nil)
|
||||||
|
if len(off) != 2 || off[0].ID != "new" || off[1].ID != "old" {
|
||||||
|
t.Fatalf("offline order = %+v, want new then old", off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCorruptFileStartsEmptyAndRecovers(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := Open(path, 1000, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Open corrupt file: want error, got nil")
|
||||||
|
}
|
||||||
|
if len(s.devices) != 0 {
|
||||||
|
t.Fatalf("corrupt load produced %d records, want 0", len(s.devices))
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Fatalf("Flush after corrupt load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reopened, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = reopened.Close() })
|
||||||
|
if len(reopened.devices) != 1 {
|
||||||
|
t.Fatalf("records after recovery = %d, want 1", len(reopened.devices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreRemovePersistsDeletion(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "devices.json")
|
||||||
|
s, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := time.Unix(1_700_000_000, 0)
|
||||||
|
s.upsertAt(testDevice("gone", "Gone"), base)
|
||||||
|
s.upsertAt(testDevice("kept", "Kept"), base.Add(time.Minute))
|
||||||
|
|
||||||
|
if !s.Remove("gone") {
|
||||||
|
t.Fatal("Remove existing record = false, want true")
|
||||||
|
}
|
||||||
|
if s.Remove("gone") {
|
||||||
|
t.Fatal("Remove missing record = true, want false")
|
||||||
|
}
|
||||||
|
if got := s.Offline(nil); len(got) != 1 || got[0].ID != "kept" {
|
||||||
|
t.Fatalf("offline after remove = %+v, want only kept", got)
|
||||||
|
}
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Fatalf("Flush: %v", err)
|
||||||
|
}
|
||||||
|
_ = s.Close()
|
||||||
|
|
||||||
|
reopened, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = reopened.Close() })
|
||||||
|
if _, ok := reopened.devices["gone"]; ok {
|
||||||
|
t.Fatal("removed record came back after reload")
|
||||||
|
}
|
||||||
|
if _, ok := reopened.devices["kept"]; !ok {
|
||||||
|
t.Fatal("kept record missing after reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlushLeavesNoTempFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "devices.json")
|
||||||
|
s, err := Open(path, 1000, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Fatalf("Flush: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasSuffix(e.Name(), ".tmp") {
|
||||||
|
t.Fatalf("leftover temp file: %s", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,6 +165,9 @@ type DeviceInfo struct {
|
|||||||
ActiveWindow string `json:"activeWindow,omitempty"`
|
ActiveWindow string `json:"activeWindow,omitempty"`
|
||||||
ConnectedAt int64 `json:"connected_at"`
|
ConnectedAt int64 `json:"connected_at"`
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
|
// LastSeen is unix seconds of the last login/disconnect. Only populated
|
||||||
|
// for offline entries in device_list; online devices leave it zero.
|
||||||
|
LastSeen int64 `json:"last_seen,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventHandler receives notifications about device lifecycle, per-tick live
|
// EventHandler receives notifications about device lifecycle, per-tick live
|
||||||
@@ -187,6 +190,9 @@ type EventHandler interface {
|
|||||||
// Duplicates (same index as the previous frame) are filtered out by the
|
// Duplicates (same index as the previous frame) are filtered out by the
|
||||||
// hub before reaching subscribers.
|
// hub before reaching subscribers.
|
||||||
OnCursorChange(deviceID string, index byte)
|
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
|
// OnTerminalReady fires once the device's shell sub-conn is bound and
|
||||||
// the server has sent COMMAND_NEXT to start its output read loop.
|
// the server has sent COMMAND_NEXT to start its output read loop.
|
||||||
// isPTY=true means PTY mode (Linux/macOS or ConPTY); false means the
|
// 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
|
h.sender = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsOnline reports whether id is currently registered with a live main
|
||||||
|
// connection. Callers that need to choose between an online-only command and
|
||||||
|
// an offline bookkeeping action should check this first; SendToDevice can
|
||||||
|
// still turn into ErrDeviceOffline if the device drops in between.
|
||||||
|
func (h *Hub) IsOnline(id string) bool {
|
||||||
|
h.mu.RLock()
|
||||||
|
d, ok := h.devices[id]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
return ok && d.conn != nil
|
||||||
|
}
|
||||||
|
|
||||||
// SendToDevice forwards an already-formed command payload to the device's
|
// SendToDevice forwards an already-formed command payload to the device's
|
||||||
// main connection. data should be the raw command bytes (the sender takes
|
// main connection. data should be the raw command bytes (the sender takes
|
||||||
// care of framing / compression at the protocol layer).
|
// care of framing / compression at the protocol layer).
|
||||||
@@ -578,6 +595,13 @@ func (h *Hub) PublishResolution(deviceID string, width, height int) {
|
|||||||
if d, ok := h.devices[deviceID]; ok {
|
if d, ok := h.devices[deviceID]; ok {
|
||||||
d.screenWidth = width
|
d.screenWidth = width
|
||||||
d.screenHeight = height
|
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()
|
h.mu.Unlock()
|
||||||
for _, s := range h.snapshotSubscribers() {
|
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
|
// 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
|
// 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
|
// 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) OnCursorChange(_ string, _ byte) {}
|
||||||
|
|
||||||
|
func (c *captureHandler) OnClipboardText(_ string, _ string) {}
|
||||||
|
|
||||||
func (c *captureHandler) OnTerminalReady(_ string, _ bool) {}
|
func (c *captureHandler) OnTerminalReady(_ string, _ bool) {}
|
||||||
|
|
||||||
func (c *captureHandler) OnTerminalData(_ string, _ []byte) {}
|
func (c *captureHandler) OnTerminalData(_ string, _ []byte) {}
|
||||||
|
|||||||
+185
-16
@@ -1,10 +1,12 @@
|
|||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"gopkg.in/natefinch/lumberjack.v2"
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
@@ -24,6 +26,13 @@ const (
|
|||||||
// Logger wraps zerolog.Logger
|
// Logger wraps zerolog.Logger
|
||||||
type Logger struct {
|
type Logger struct {
|
||||||
zl zerolog.Logger
|
zl zerolog.Logger
|
||||||
|
// audit is a second, file-only logger used for security audit records.
|
||||||
|
// It writes JSON lines to AuditFile so audit data is not buried in
|
||||||
|
// debug/operational output and can be retained independently.
|
||||||
|
audit zerolog.Logger
|
||||||
|
auditEnabled bool
|
||||||
|
auditPath string // "" when the separate audit file is disabled
|
||||||
|
closers []io.Closer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config holds logger configuration
|
// Config holds logger configuration
|
||||||
@@ -42,6 +51,19 @@ type Config struct {
|
|||||||
MaxAge int
|
MaxAge int
|
||||||
// Compress enables gzip compression for rotated files
|
// Compress enables gzip compression for rotated files
|
||||||
Compress bool
|
Compress bool
|
||||||
|
|
||||||
|
// AuditFile is the dedicated security-audit log path ("" disables the
|
||||||
|
// separate audit file; audit records still go to the main log). Audit
|
||||||
|
// records are JSON lines with audit=true, event=<name>, and contextual
|
||||||
|
// fields such as actor/role/ip/device_id.
|
||||||
|
AuditFile string
|
||||||
|
// AuditMaxSize / AuditMaxBackups / AuditMaxAge tune audit rotation.
|
||||||
|
// Zero values pick 100 MB / 20 files / 90 days.
|
||||||
|
AuditMaxSize int
|
||||||
|
AuditMaxBackups int
|
||||||
|
AuditMaxAge int
|
||||||
|
// AuditCompress enables gzip compression for rotated audit files.
|
||||||
|
AuditCompress bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns default configuration
|
// DefaultConfig returns default configuration
|
||||||
@@ -60,6 +82,7 @@ func DefaultConfig() Config {
|
|||||||
// New creates a new logger with config
|
// New creates a new logger with config
|
||||||
func New(cfg Config) *Logger {
|
func New(cfg Config) *Logger {
|
||||||
var writers []io.Writer
|
var writers []io.Writer
|
||||||
|
var closers []io.Closer
|
||||||
|
|
||||||
// Console output with color
|
// Console output with color
|
||||||
if cfg.Console {
|
if cfg.Console {
|
||||||
@@ -87,6 +110,7 @@ func New(cfg Config) *Logger {
|
|||||||
LocalTime: true,
|
LocalTime: true,
|
||||||
}
|
}
|
||||||
writers = append(writers, fileWriter)
|
writers = append(writers, fileWriter)
|
||||||
|
closers = append(closers, fileWriter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine writers
|
// Combine writers
|
||||||
@@ -106,14 +130,78 @@ func New(cfg Config) *Logger {
|
|||||||
Timestamp().
|
Timestamp().
|
||||||
Logger()
|
Logger()
|
||||||
|
|
||||||
return &Logger{zl: zl}
|
l := &Logger{zl: zl, closers: closers}
|
||||||
|
|
||||||
|
if cfg.AuditFile != "" {
|
||||||
|
dir := filepath.Dir(cfg.AuditFile)
|
||||||
|
if dir != "" && dir != "." {
|
||||||
|
_ = os.MkdirAll(dir, 0755)
|
||||||
|
}
|
||||||
|
maxSize := cfg.AuditMaxSize
|
||||||
|
if maxSize <= 0 {
|
||||||
|
maxSize = 100
|
||||||
|
}
|
||||||
|
maxBackups := cfg.AuditMaxBackups
|
||||||
|
if maxBackups <= 0 {
|
||||||
|
maxBackups = 20
|
||||||
|
}
|
||||||
|
maxAge := cfg.AuditMaxAge
|
||||||
|
if maxAge <= 0 {
|
||||||
|
maxAge = 90
|
||||||
|
}
|
||||||
|
auditWriter := &lumberjack.Logger{
|
||||||
|
Filename: cfg.AuditFile,
|
||||||
|
MaxSize: maxSize,
|
||||||
|
MaxBackups: maxBackups,
|
||||||
|
MaxAge: maxAge,
|
||||||
|
Compress: cfg.AuditCompress,
|
||||||
|
LocalTime: true,
|
||||||
|
}
|
||||||
|
l.audit = zerolog.New(auditWriter).
|
||||||
|
Level(zerolog.InfoLevel).
|
||||||
|
With().
|
||||||
|
Timestamp().
|
||||||
|
Logger()
|
||||||
|
l.auditEnabled = true
|
||||||
|
l.auditPath = cfg.AuditFile
|
||||||
|
l.closers = append(l.closers, auditWriter)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases any file handles owned by the logger. Safe to call multiple
|
||||||
|
// times. Loggers derived with WithPrefix share the base logger's files but do
|
||||||
|
// not own them, so call Close on the logger returned by New.
|
||||||
|
func (l *Logger) Close() error {
|
||||||
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, c := range l.closers {
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
|
l.closers = nil
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithPrefix returns a new logger with a prefix field
|
// WithPrefix returns a new logger with a prefix field
|
||||||
func (l *Logger) WithPrefix(prefix string) *Logger {
|
func (l *Logger) WithPrefix(prefix string) *Logger {
|
||||||
return &Logger{
|
n := &Logger{zl: l.zl.With().Str("module", prefix).Logger(), auditPath: l.auditPath}
|
||||||
zl: l.zl.With().Str("module", prefix).Logger(),
|
if l.auditEnabled {
|
||||||
|
n.audit = l.audit.With().Str("module", prefix).Logger()
|
||||||
|
n.auditEnabled = true
|
||||||
}
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditPath returns the path of the dedicated audit log file, or "" when the
|
||||||
|
// separate audit file is disabled (audit records then only reach the main log).
|
||||||
|
// Derived loggers (WithPrefix) report the same path.
|
||||||
|
func (l *Logger) AuditPath() string {
|
||||||
|
if l == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l.auditPath
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logs a debug message
|
// Debug logs a debug message
|
||||||
@@ -151,21 +239,102 @@ func (l *Logger) GetLevel() Level {
|
|||||||
return l.zl.GetLevel()
|
return l.zl.GetLevel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientEvent logs client online/offline events
|
// ClientEvent logs client online/offline events as audit records.
|
||||||
func (l *Logger) ClientEvent(event string, clientID uint64, ip string, extra ...string) {
|
func (l *Logger) ClientEvent(event string, clientID uint64, ip string, extra ...string) {
|
||||||
e := l.zl.Info().
|
// clientID is the TCP connection id; the device's stable YAMA id arrives
|
||||||
Str("event", event).
|
// as the "clientID" extra and is normalized to device_id so every audit
|
||||||
Uint64("client_id", clientID).
|
// record uses the same field names (conn / device_id).
|
||||||
Str("ip", ip).
|
fields := map[string]any{
|
||||||
Time("time", time.Now())
|
"conn": clientID,
|
||||||
|
"ip": ip,
|
||||||
if len(extra) >= 2 {
|
|
||||||
for i := 0; i+1 < len(extra); i += 2 {
|
|
||||||
e = e.Str(extra[i], extra[i+1])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
for i := 0; i+1 < len(extra); i += 2 {
|
||||||
|
key := extra[i]
|
||||||
|
if key == "clientID" {
|
||||||
|
key = "device_id"
|
||||||
|
}
|
||||||
|
fields[key] = extra[i+1]
|
||||||
|
}
|
||||||
|
l.Audit("device."+event, fields)
|
||||||
|
}
|
||||||
|
|
||||||
e.Msg("")
|
// Audit writes one security-audit record at info level.
|
||||||
|
func (l *Logger) Audit(event string, fields map[string]any) {
|
||||||
|
l.AuditAt(LevelInfo, event, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditWarn writes one security-audit record at warn level. Use it for
|
||||||
|
// rejected / failed security-relevant operations.
|
||||||
|
func (l *Logger) AuditWarn(event string, fields map[string]any) {
|
||||||
|
l.AuditAt(LevelWarn, event, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditAt writes a security-audit record to the dedicated audit file (when
|
||||||
|
// configured) and mirrors it to the main logger so operators tailing
|
||||||
|
// server.log still see security events. event is a stable dotted name such
|
||||||
|
// as "web.login.failure"; fields carry actor/role/ip/device_id/reason.
|
||||||
|
//
|
||||||
|
// Untrusted string values (usernames, User-Agent, Origin, device strings)
|
||||||
|
// are sanitized: control characters are replaced so they cannot forge log
|
||||||
|
// lines, and long values are truncated.
|
||||||
|
func (l *Logger) AuditAt(level Level, event string, fields map[string]any) {
|
||||||
|
if l == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clean := make(map[string]any, len(fields)+2)
|
||||||
|
for k, v := range fields {
|
||||||
|
clean[k] = sanitizeAuditValue(v)
|
||||||
|
}
|
||||||
|
clean["audit"] = true
|
||||||
|
clean["event"] = event
|
||||||
|
|
||||||
|
if l.auditEnabled {
|
||||||
|
l.audit.WithLevel(level).Fields(clean).Msg("audit")
|
||||||
|
}
|
||||||
|
l.zl.WithLevel(level).Fields(clean).Msg("audit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxAuditValueLen bounds untrusted log values (runes). A 1 MB WebSocket
|
||||||
|
// frame could otherwise be reflected verbatim into every audit line.
|
||||||
|
const maxAuditValueLen = 512
|
||||||
|
|
||||||
|
// maxAuditValueBytes is the pre-sanitization byte cap. It keeps a single
|
||||||
|
// hostile value from allocating an unbounded rune slice before truncation.
|
||||||
|
const maxAuditValueBytes = 4096
|
||||||
|
|
||||||
|
func sanitizeAuditValue(v any) any {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
return sanitizeAuditString(x)
|
||||||
|
case []byte:
|
||||||
|
return sanitizeAuditString(string(x))
|
||||||
|
case error:
|
||||||
|
return sanitizeAuditString(x.Error())
|
||||||
|
case fmt.Stringer:
|
||||||
|
return sanitizeAuditString(x.String())
|
||||||
|
default:
|
||||||
|
// Numeric/bool values are code-controlled and log-safe.
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeAuditString(s string) string {
|
||||||
|
if len(s) > maxAuditValueBytes {
|
||||||
|
s = s[:maxAuditValueBytes]
|
||||||
|
}
|
||||||
|
s = strings.Map(func(r rune) rune {
|
||||||
|
// C0 controls, DEL, and Unicode line/paragraph separators can be
|
||||||
|
// used to forge or visually split log lines in console output.
|
||||||
|
if r < 0x20 || r == 0x7f || r == 0x2028 || r == 0x2029 {
|
||||||
|
return ' '
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, s)
|
||||||
|
if utf8.RuneCountInString(s) > maxAuditValueLen {
|
||||||
|
r := []rune(s)
|
||||||
|
s = string(r[:maxAuditValueLen])
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// default global logger
|
// default global logger
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readAuditLines parses logs/audit.log-style JSON lines. Callers assert on
|
||||||
|
// individual records rather than raw text so field ordering is irrelevant.
|
||||||
|
func readAuditLines(t *testing.T, path string) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open audit log: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var out []map[string]any
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||||
|
t.Fatalf("audit line is not JSON: %q: %v", line, err)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
t.Fatalf("scan audit log: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditWritesDedicatedFileAndMirrorsMainLog(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
auditPath := filepath.Join(dir, "audit.log")
|
||||||
|
mainPath := filepath.Join(dir, "server.log")
|
||||||
|
|
||||||
|
l := New(Config{
|
||||||
|
Level: LevelInfo,
|
||||||
|
Console: false,
|
||||||
|
File: mainPath,
|
||||||
|
AuditFile: auditPath,
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { _ = l.Close() })
|
||||||
|
|
||||||
|
l.Audit("web.login.success", map[string]any{
|
||||||
|
"actor": "admin",
|
||||||
|
"role": "admin",
|
||||||
|
"ip": "203.0.113.9",
|
||||||
|
})
|
||||||
|
|
||||||
|
lines := readAuditLines(t, auditPath)
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("audit lines = %d, want 1", len(lines))
|
||||||
|
}
|
||||||
|
rec := lines[0]
|
||||||
|
if rec["audit"] != true {
|
||||||
|
t.Errorf("audit = %v, want true", rec["audit"])
|
||||||
|
}
|
||||||
|
if rec["event"] != "web.login.success" {
|
||||||
|
t.Errorf("event = %v", rec["event"])
|
||||||
|
}
|
||||||
|
if rec["actor"] != "admin" || rec["role"] != "admin" || rec["ip"] != "203.0.113.9" {
|
||||||
|
t.Errorf("fields = %+v", rec)
|
||||||
|
}
|
||||||
|
if _, ok := rec["time"]; !ok {
|
||||||
|
t.Error("record has no timestamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same record must reach server.log so operators tailing one file
|
||||||
|
// still see security events.
|
||||||
|
mainLines := readAuditLines(t, mainPath)
|
||||||
|
found := false
|
||||||
|
for _, m := range mainLines {
|
||||||
|
if m["event"] == "web.login.success" && m["audit"] == true {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("audit record not mirrored to main log: %+v", mainLines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditSanitizesUntrustedValues(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
auditPath := filepath.Join(dir, "audit.log")
|
||||||
|
l := New(Config{Console: false, File: filepath.Join(dir, "server.log"), AuditFile: auditPath})
|
||||||
|
t.Cleanup(func() { _ = l.Close() })
|
||||||
|
|
||||||
|
l.Audit("web.login.failure", map[string]any{
|
||||||
|
// Newlines must not be able to forge extra log lines.
|
||||||
|
"username": "evil\n{\"audit\":true,\"event\":\"web.login.success\"}",
|
||||||
|
"ua": strings.Repeat("A", maxAuditValueLen+50),
|
||||||
|
})
|
||||||
|
|
||||||
|
lines := readAuditLines(t, auditPath)
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("audit lines = %d, want 1 (newline forged a record?)", len(lines))
|
||||||
|
}
|
||||||
|
username, _ := lines[0]["username"].(string)
|
||||||
|
if strings.ContainsAny(username, "\n\r") {
|
||||||
|
t.Errorf("username still contains a newline: %q", username)
|
||||||
|
}
|
||||||
|
if !strings.Contains(username, `{"audit":true`) {
|
||||||
|
t.Errorf("username content lost: %q", username)
|
||||||
|
}
|
||||||
|
ua, _ := lines[0]["ua"].(string)
|
||||||
|
if got := len([]rune(ua)); got != maxAuditValueLen {
|
||||||
|
t.Errorf("ua rune length = %d, want %d", got, maxAuditValueLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditDisabledFileStillLogsToMain(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mainPath := filepath.Join(dir, "server.log")
|
||||||
|
l := New(Config{Console: false, File: mainPath}) // no AuditFile
|
||||||
|
t.Cleanup(func() { _ = l.Close() })
|
||||||
|
|
||||||
|
l.AuditWarn("web.authz.denied", map[string]any{"role": "viewer"})
|
||||||
|
|
||||||
|
lines := readAuditLines(t, mainPath)
|
||||||
|
if len(lines) != 1 || lines[0]["event"] != "web.authz.denied" {
|
||||||
|
t.Fatalf("main log lines = %+v", lines)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "audit.log")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("audit.log unexpectedly created: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientEventEmitsDeviceAudit(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
auditPath := filepath.Join(dir, "audit.log")
|
||||||
|
l := New(Config{Console: false, File: filepath.Join(dir, "server.log"), AuditFile: auditPath})
|
||||||
|
t.Cleanup(func() { _ = l.Close() })
|
||||||
|
|
||||||
|
l.ClientEvent("online", 42, "198.51.100.7", "clientID", "abc", "computer", "PC-01")
|
||||||
|
|
||||||
|
lines := readAuditLines(t, auditPath)
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("audit lines = %d, want 1", len(lines))
|
||||||
|
}
|
||||||
|
if lines[0]["event"] != "device.online" {
|
||||||
|
t.Errorf("event = %v, want device.online", lines[0]["event"])
|
||||||
|
}
|
||||||
|
if lines[0]["device_id"] != "abc" {
|
||||||
|
t.Errorf("device_id = %v", lines[0]["device_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,15 +103,19 @@ func DecodeClientString(data []byte, capability, clientType string) string {
|
|||||||
// Command tokens - matching the C++ definitions (common/commands.h).
|
// Command tokens - matching the C++ definitions (common/commands.h).
|
||||||
const (
|
const (
|
||||||
// Server -> Client commands
|
// Server -> Client commands
|
||||||
CommandActived byte = 0 // COMMAND_ACTIVED
|
CommandActived byte = 0 // COMMAND_ACTIVED
|
||||||
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
|
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
|
||||||
CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches)
|
CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches)
|
||||||
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
|
CommandScreenGetClipboard byte = 24 // COMMAND_SCREEN_GET_CLIPBOARD - request remote clipboard (single byte)
|
||||||
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
|
CommandScreenSetClipboard byte = 25 // COMMAND_SCREEN_SET_CLIPBOARD - set remote clipboard [25][GBK text]
|
||||||
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
|
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
|
||||||
CmdRestoreConsole byte = 82 // CMD_RESTORE_CONSOLE - RDP session "归位": switch back to the console session and restart capture
|
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
|
||||||
CommandBye byte = 204 // COMMAND_BYE - disconnect
|
CommandUninstall byte = 63 // TOKEN_UNINSTALL - uninstall the host agent (single byte)
|
||||||
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
|
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
|
// Client -> Server tokens
|
||||||
TokenAuth byte = 100 // TOKEN_AUTH - authorization required
|
TokenAuth byte = 100 // TOKEN_AUTH - authorization required
|
||||||
@@ -120,12 +124,14 @@ const (
|
|||||||
TokenBitmapInfo byte = 115 // TOKEN_BITMAPINFO - screen sub-connection header
|
TokenBitmapInfo byte = 115 // TOKEN_BITMAPINFO - screen sub-connection header
|
||||||
TokenFirstScreen byte = 116 // TOKEN_FIRSTSCREEN - raw BGRA baseline frame (NOT H264)
|
TokenFirstScreen byte = 116 // TOKEN_FIRSTSCREEN - raw BGRA baseline frame (NOT H264)
|
||||||
TokenNextScreen byte = 117 // TOKEN_NEXTSCREEN - non-keyframe H264 (P-frame)
|
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
|
TokenShellStart byte = 128 // TOKEN_SHELL_START - legacy cmd-pipe shell sub-conn open
|
||||||
TokenKeyframe byte = 134 // TOKEN_KEYFRAME - H264 IDR (sent on GOP boundary)
|
TokenKeyframe byte = 134 // TOKEN_KEYFRAME - H264 IDR (sent on GOP boundary)
|
||||||
TokenTerminalStart byte = 232 // TOKEN_TERMINAL_START - modern PTY shell sub-conn open
|
TokenTerminalStart byte = 232 // TOKEN_TERMINAL_START - modern PTY shell sub-conn open
|
||||||
TokenTerminalClose byte = 233 // TOKEN_TERMINAL_CLOSE - shell exited / close ack
|
TokenTerminalClose byte = 233 // TOKEN_TERMINAL_CLOSE - shell exited / close ack
|
||||||
TokenConnAuth byte = 246 // TOKEN_CONN_AUTH - sub-connection identity handshake
|
TokenConnAuth byte = 246 // TOKEN_CONN_AUTH - sub-connection identity handshake
|
||||||
CmdCursorImage byte = 93 // CMD_CURSOR_IMAGE - custom cursor bitmap (Phase 5+ feature)
|
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).
|
// Sub-connection authentication (matches common/commands.h ConnAuth* structs).
|
||||||
@@ -140,11 +146,26 @@ const (
|
|||||||
ConnAuthOffClientID = 1 // uint64 LE — pin to the sub-conn so later
|
ConnAuthOffClientID = 1 // uint64 LE — pin to the sub-conn so later
|
||||||
// // 1-byte tokens (TOKEN_TERMINAL_START etc.) can
|
// // 1-byte tokens (TOKEN_TERMINAL_START etc.) can
|
||||||
// // resolve the parent device.
|
// // resolve the parent device.
|
||||||
|
ConnAuthOffTimestamp = 9 // uint64 LE - Unix seconds, anti-replay window
|
||||||
|
ConnAuthOffNonce = 17 // [16]byte - part of the signed message
|
||||||
|
ConnAuthOffSignature = 33 // [64]byte - hex of HMAC-SHA256(key, clientID||timestamp||nonce)
|
||||||
|
ConnAuthSignatureSize = 64
|
||||||
|
|
||||||
// ConnAuthAck field offsets within the outbound 256-byte buffer.
|
// ConnAuthAck field offsets within the outbound 256-byte buffer.
|
||||||
ConnAuthAckOffStatus = 1 // uint8
|
ConnAuthAckOffStatus = 1 // uint8
|
||||||
ConnAuthAckOffServerTime = 2 // uint64 LE
|
ConnAuthAckOffServerTime = 2 // uint64 LE
|
||||||
// Status codes.
|
// ConnAuthTimestampToleranceSec bounds |server - client| clock skew in
|
||||||
ConnAuthStatusOK byte = 0
|
// seconds (matches CONN_AUTH_TIMESTAMP_TOLERANCE_SEC in common/commands.h).
|
||||||
|
// A timestamp outside this window is treated as replayed or forged.
|
||||||
|
ConnAuthTimestampToleranceSec = 300
|
||||||
|
|
||||||
|
// Status codes (match common/commands.h::ConnAuthStatus). The client treats
|
||||||
|
// any non-zero status as failure and drops the sub-connection.
|
||||||
|
ConnAuthStatusOK byte = 0
|
||||||
|
ConnAuthStatusBadSize byte = 1 // packet shorter than the signature field
|
||||||
|
ConnAuthStatusClockSkew byte = 2 // timestamp outside tolerance
|
||||||
|
ConnAuthStatusBadSignature byte = 3 // HMAC mismatch
|
||||||
|
ConnAuthStatusInternalError byte = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
// CMD_MASTERSETTING is the server's reply to a fresh client login. The
|
// CMD_MASTERSETTING is the server's reply to a fresh client login. The
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -40,6 +41,13 @@ type Config struct {
|
|||||||
PerIPMaxConnections int // max concurrent connections per source IP (0 = unlimited)
|
PerIPMaxConnections int // max concurrent connections per source IP (0 = unlimited)
|
||||||
PerIPConnRateLimit int // max new connections per PerIPConnRateWindow per IP (0 = unlimited)
|
PerIPConnRateLimit int // max new connections per PerIPConnRateWindow per IP (0 = unlimited)
|
||||||
PerIPConnRateWindow time.Duration // window for PerIPConnRateLimit
|
PerIPConnRateWindow time.Duration // window for PerIPConnRateLimit
|
||||||
|
|
||||||
|
// TLSCertFile / TLSKeyFile enable optional one-way TLS on the device port.
|
||||||
|
// When both are set the server loads the cert and auto-detects TLS clients
|
||||||
|
// by their ClientHello; plaintext clients keep working unchanged. Both empty
|
||||||
|
// (the default) disables TLS and behaves exactly as before.
|
||||||
|
TLSCertFile string // server certificate (PEM)
|
||||||
|
TLSKeyFile string // server private key (PEM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns default configuration
|
// DefaultConfig returns default configuration
|
||||||
@@ -75,6 +83,10 @@ type Server struct {
|
|||||||
parser *protocol.Parser
|
parser *protocol.Parser
|
||||||
ipLimit *ipLimiter
|
ipLimit *ipLimiter
|
||||||
|
|
||||||
|
// tlsCfg is non-nil when one-way TLS is enabled (cert + key configured).
|
||||||
|
// wrapTLS consults it to sniff and wrap incoming connections.
|
||||||
|
tlsCfg *tls.Config
|
||||||
|
|
||||||
running atomic.Bool
|
running atomic.Bool
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -234,6 +246,12 @@ func (s *Server) SetLogger(l *logger.Logger) {
|
|||||||
|
|
||||||
// Start starts the server
|
// Start starts the server
|
||||||
func (s *Server) Start() error {
|
func (s *Server) Start() error {
|
||||||
|
// Load optional one-way TLS cert up front so a bad path/key fails startup
|
||||||
|
// loudly rather than silently serving plaintext.
|
||||||
|
if err := s.loadTLSConfig(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
addr := net.JoinHostPort("0.0.0.0", itoa(s.config.Port))
|
addr := net.JoinHostPort("0.0.0.0", itoa(s.config.Port))
|
||||||
listener, err := net.Listen("tcp", addr)
|
listener, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -360,7 +378,10 @@ func (s *Server) acceptLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleConnection handles a single connection
|
// handleConnection handles a single connection
|
||||||
func (s *Server) handleConnection(conn net.Conn) {
|
func (s *Server) handleConnection(rawConn net.Conn) {
|
||||||
|
// Sniff and (when TLS is enabled) wrap TLS peers; plaintext passes through.
|
||||||
|
conn := s.wrapTLS(rawConn)
|
||||||
|
|
||||||
// Create context
|
// Create context
|
||||||
ctx := connection.NewContext(conn, nil)
|
ctx := connection.NewContext(conn, nil)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadTLSConfig builds the server's one-way TLS configuration from the
|
||||||
|
// configured cert/key paths, leaving s.tlsCfg nil when TLS is disabled. A cert
|
||||||
|
// path set without its key (or vice versa) is a misconfiguration: TLS is left
|
||||||
|
// disabled and a warning is logged rather than guessing. A load failure is
|
||||||
|
// returned to the caller so startup fails loudly instead of silently serving
|
||||||
|
// plaintext.
|
||||||
|
func (s *Server) loadTLSConfig() error {
|
||||||
|
if s.config.TLSCertFile == "" && s.config.TLSKeyFile == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.config.TLSCertFile == "" || s.config.TLSKeyFile == "" {
|
||||||
|
s.logger.Warn("TLS disabled: both YAMA_TLS_CERT and YAMA_TLS_KEY must be set (cert=%q key=%q)",
|
||||||
|
s.config.TLSCertFile, s.config.TLSKeyFile)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := tls.LoadX509KeyPair(s.config.TLSCertFile, s.config.TLSKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load TLS cert/key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.tlsCfg = &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
// One-way TLS: no ClientAuth / ClientCAs. Client identity is still
|
||||||
|
// established by the in-tunnel login (V1 PwdHmac / V2 ECDSA).
|
||||||
|
}
|
||||||
|
s.logger.Info("TLS enabled (one-way): cert=%s", s.config.TLSCertFile)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTLSClientHello reports whether b begins with a TLS handshake record whose
|
||||||
|
// first message is a ClientHello:
|
||||||
|
//
|
||||||
|
// 0x16 — handshake record type
|
||||||
|
// 0x03 — TLS major version (all TLS 1.x / 1.3 records)
|
||||||
|
// 0x01 (b[5])— handshake message type ClientHello
|
||||||
|
//
|
||||||
|
// The first two bytes alone are insufficient: the C++ client obfuscates its
|
||||||
|
// "HELL" header (common/header.h GetHead) with a per-connection key, so a
|
||||||
|
// plaintext client can in principle emit 0x16 0x03 as its first two bytes. For
|
||||||
|
// that obfuscated header byte 5 is always 0x00, and for the plain "Shine"/
|
||||||
|
// "POST" variants it is an ASCII byte — never 0x01. Requiring b[5]==0x01 makes
|
||||||
|
// a false positive effectively impossible.
|
||||||
|
func isTLSClientHello(b []byte) bool {
|
||||||
|
return len(b) >= 6 && b[0] == 0x16 && b[1] == 0x03 && b[5] == 0x01
|
||||||
|
}
|
||||||
|
|
||||||
|
// bufferedConn replays bytes already peeked from a net.Conn, so the caller can
|
||||||
|
// sniff a prefix and then hand the connection (peeked bytes included) to the
|
||||||
|
// TLS or plaintext read loop. Read delegates to the buffered reader; everything
|
||||||
|
// else delegates to the embedded net.Conn.
|
||||||
|
type bufferedConn struct {
|
||||||
|
net.Conn
|
||||||
|
r *bufio.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *bufferedConn) Read(p []byte) (int, error) {
|
||||||
|
return c.r.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapTLS sniffs the first bytes of a freshly accepted connection and, when TLS
|
||||||
|
// is enabled and the peer is speaking TLS, returns a tls.Conn wrapper. The TLS
|
||||||
|
// handshake is lazy: it runs on the first Read, which handleConnection already
|
||||||
|
// bounds with its ReadTimeout. Plaintext peers are returned unchanged (wrapped
|
||||||
|
// in a bufferedConn so the sniffed prefix is replayed).
|
||||||
|
func (s *Server) wrapTLS(conn net.Conn) net.Conn {
|
||||||
|
// NewContext only disables Nagle on a *net.TCPConn; once we wrap the conn
|
||||||
|
// (bufferedConn / tls.Conn) that assertion no longer fires, so disable it
|
||||||
|
// here on the raw conn to preserve screen-handshake responsiveness.
|
||||||
|
if tcp, ok := conn.(*net.TCPConn); ok {
|
||||||
|
_ = tcp.SetNoDelay(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.tlsCfg == nil {
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
br := bufio.NewReader(conn)
|
||||||
|
if s.config.ReadTimeout > 0 {
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(s.config.ReadTimeout))
|
||||||
|
}
|
||||||
|
hdr, err := br.Peek(6)
|
||||||
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
|
// Peek failed (timeout / EOF): the peer never delivered enough bytes to
|
||||||
|
// identify itself. Fall through with the raw conn so the read loop surfaces
|
||||||
|
// EOF/timeout exactly as it does for the plaintext path today. Any <6
|
||||||
|
// partial bytes already buffered are discarded, which only affects peers
|
||||||
|
// that were already broken (real clients send ≥13 bytes immediately).
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("wrapTLS peek failed for %s: %v", conn.RemoteAddr(), err)
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
bc := &bufferedConn{Conn: conn, r: br}
|
||||||
|
if isTLSClientHello(hdr) {
|
||||||
|
s.logger.Debug("wrapTLS: TLS peer %s first6=%x", conn.RemoteAddr(), hdr)
|
||||||
|
return tls.Server(bc, s.tlsCfg)
|
||||||
|
}
|
||||||
|
s.logger.Debug("wrapTLS: plaintext peer %s first6=%x", conn.RemoteAddr(), hdr)
|
||||||
|
return bc
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsTLSClientHello(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
b []byte
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "TLS 1.2 ClientHello",
|
||||||
|
b: []byte{0x16, 0x03, 0x03, 0x00, 0x01, 0x01, 0x00},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TLS 1.3 ClientHello (legacy record version)",
|
||||||
|
b: []byte{0x16, 0x03, 0x01, 0x00, 0x01, 0x01, 0x00},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plaintext Shine flag",
|
||||||
|
b: []byte{'S', 'h', 'i', 'n', 'e', 0x00, 0x00},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "obfuscated HELL header that happens to start 0x16 0x03 but byte5=0x00",
|
||||||
|
b: []byte{0x16, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTP-mask POST envelope",
|
||||||
|
b: []byte{'P', 'O', 'S', 'T', ' ', '/', 'x'},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "0x16 0x03 but handshake type ServerHello (0x02), not ClientHello",
|
||||||
|
b: []byte{0x16, 0x03, 0x03, 0x00, 0x01, 0x02, 0x00},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too short",
|
||||||
|
b: []byte{0x16, 0x03, 0x01},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := isTLSClientHello(tt.b); got != tt.want {
|
||||||
|
t.Errorf("isTLSClientHello(% x) = %v, want %v", tt.b, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
-10
@@ -6,13 +6,17 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||||
@@ -27,12 +31,14 @@ type Server struct {
|
|||||||
srv *http.Server
|
srv *http.Server
|
||||||
hub *hub.Hub
|
hub *hub.Hub
|
||||||
auth *wsauth.Authenticator
|
auth *wsauth.Authenticator
|
||||||
|
history *devicehistory.Store // nil → no offline devices
|
||||||
ws *wsHub
|
ws *wsHub
|
||||||
allowedOrigins []string // for WS Origin allowlist; empty = same-origin only
|
allowedOrigins []string // for WS Origin allowlist; empty = same-origin only
|
||||||
loginIPLimit *wsauth.RateLimiter
|
loginIPLimit *wsauth.RateLimiter
|
||||||
loginUserLimit *wsauth.RateLimiter
|
loginUserLimit *wsauth.RateLimiter
|
||||||
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
|
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
|
||||||
basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root
|
basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root
|
||||||
|
brandName string // white-label title; "" = "SimpleRemoter" (official branding)
|
||||||
sso SSODetector // nil = SSO disabled
|
sso SSODetector // nil = SSO disabled
|
||||||
sweepDone chan struct{} // closes to stop the login-limiter sweeper
|
sweepDone chan struct{} // closes to stop the login-limiter sweeper
|
||||||
sweepStopOnce sync.Once
|
sweepStopOnce sync.Once
|
||||||
@@ -64,6 +70,11 @@ type Config struct {
|
|||||||
// https://example.com/remoter). Must be empty or a leading-slash,
|
// https://example.com/remoter). Must be empty or a leading-slash,
|
||||||
// no-trailing-slash path; "" (default) serves from the domain root.
|
// no-trailing-slash path; "" (default) serves from the domain root.
|
||||||
BasePath string
|
BasePath string
|
||||||
|
// BrandName is the white-label product title shown in index.html and the
|
||||||
|
// PWA manifest when BasePath is non-empty (i.e. mounted under another
|
||||||
|
// domain). Read from the YAMA_WEB_BRAND_NAME env var by cmd. Empty leaves
|
||||||
|
// the official "SimpleRemoter" branding in place.
|
||||||
|
BrandName string
|
||||||
// SSO is an optional detector that maps an already-authenticated portal
|
// SSO is an optional detector that maps an already-authenticated portal
|
||||||
// session (cookie/header) into a YAMA web session via /sso. nil disables
|
// session (cookie/header) into a YAMA web session via /sso. nil disables
|
||||||
// SSO and the front-end falls back to the built-in login form.
|
// SSO and the front-end falls back to the built-in login form.
|
||||||
@@ -77,6 +88,14 @@ func New(port int, log *logger.Logger, h *hub.Hub, auth *wsauth.Authenticator) *
|
|||||||
return &Server{port: port, log: log, hub: h, auth: auth}
|
return &Server{port: port, log: log, hub: h, auth: auth}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithDeviceHistory attaches the persistent device-history store. When set,
|
||||||
|
// device_list also returns devices that have logged in before but are offline
|
||||||
|
// now. Passing nil keeps the online-only behaviour. Safe to call before Start.
|
||||||
|
func (s *Server) WithDeviceHistory(h *devicehistory.Store) *Server {
|
||||||
|
s.history = h
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// WithConfig applies hardening configuration. Returns the receiver for
|
// WithConfig applies hardening configuration. Returns the receiver for
|
||||||
// chainable setup. Safe to call before Start; ignored thereafter.
|
// chainable setup. Safe to call before Start; ignored thereafter.
|
||||||
func (s *Server) WithConfig(cfg Config) *Server {
|
func (s *Server) WithConfig(cfg Config) *Server {
|
||||||
@@ -85,6 +104,7 @@ func (s *Server) WithConfig(cfg Config) *Server {
|
|||||||
s.loginUserLimit = cfg.LoginUserLimit
|
s.loginUserLimit = cfg.LoginUserLimit
|
||||||
s.trustForwardedFor = cfg.TrustForwardedFor
|
s.trustForwardedFor = cfg.TrustForwardedFor
|
||||||
s.basePath = cfg.BasePath
|
s.basePath = cfg.BasePath
|
||||||
|
s.brandName = resolveBrandName(cfg.BrandName, s.basePath)
|
||||||
s.sso = cfg.SSO
|
s.sso = cfg.SSO
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -97,7 +117,7 @@ func (s *Server) Start() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
s.ws = newWSHub(s.auth, s.hub, s.log).
|
s.ws = newWSHub(s.auth, s.hub, s.history, s.log).
|
||||||
withOriginAllowlist(s.allowedOrigins).
|
withOriginAllowlist(s.allowedOrigins).
|
||||||
withLoginRateLimiters(s.loginIPLimit, s.loginUserLimit).
|
withLoginRateLimiters(s.loginIPLimit, s.loginUserLimit).
|
||||||
withTrustForwardedFor(s.trustForwardedFor)
|
withTrustForwardedFor(s.trustForwardedFor)
|
||||||
@@ -182,6 +202,10 @@ func (s *Server) Stop() {
|
|||||||
// configured base path at serve time. Empty base path → "" → root URLs.
|
// configured base path at serve time. Empty base path → "" → root URLs.
|
||||||
const basePathPlaceholder = "__BASE_PATH__"
|
const basePathPlaceholder = "__BASE_PATH__"
|
||||||
|
|
||||||
|
// brandPlaceholder is the token in index.html substituted with the product
|
||||||
|
// title at serve time (official or white-label, depending on BasePath).
|
||||||
|
const brandPlaceholder = "__BRAND_NAME__"
|
||||||
|
|
||||||
// mount wraps the root-path mux with the configured base path. When basePath
|
// mount wraps the root-path mux with the configured base path. When basePath
|
||||||
// is empty the mux is returned unchanged (domain-root serving, 方案A). Otherwise
|
// is empty the mux is returned unchanged (domain-root serving, 方案A). Otherwise
|
||||||
// the bare prefix redirects to prefix+"/", paths outside the prefix 404, and
|
// the bare prefix redirects to prefix+"/", paths outside the prefix 404, and
|
||||||
@@ -210,6 +234,11 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
body := bytes.ReplaceAll(IndexHTML, []byte(basePathPlaceholder), []byte(s.basePath))
|
body := bytes.ReplaceAll(IndexHTML, []byte(basePathPlaceholder), []byte(s.basePath))
|
||||||
|
body = bytes.ReplaceAll(body, []byte(brandPlaceholder), []byte(html.EscapeString(s.displayName())))
|
||||||
|
if s.isWhiteLabel() {
|
||||||
|
body = stripBrandBlock(body, "NOTICE")
|
||||||
|
body = stripBrandBlock(body, "LINKS")
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
_, _ = w.Write(body)
|
_, _ = w.Write(body)
|
||||||
@@ -273,15 +302,36 @@ func (s *Server) manifestJSON() []byte {
|
|||||||
if s.basePath != "" {
|
if s.basePath != "" {
|
||||||
start = s.basePath + "/"
|
start = s.basePath + "/"
|
||||||
}
|
}
|
||||||
return []byte(fmt.Sprintf(`{
|
name := s.displayName()
|
||||||
"name": "SimpleRemoter",
|
shortName := "Remoter"
|
||||||
"short_name": "Remoter",
|
if s.isWhiteLabel() {
|
||||||
"start_url": "%s",
|
shortName = name
|
||||||
"display": "standalone",
|
}
|
||||||
"orientation": "any",
|
// Marshal via encoding/json so an operator-supplied brand name containing
|
||||||
"background_color": "#1a1a2e",
|
// quotes or backslashes can't produce invalid JSON.
|
||||||
"theme_color": "#1a1a2e"
|
manifest := struct {
|
||||||
}`, start))
|
Name string `json:"name"`
|
||||||
|
ShortName string `json:"short_name"`
|
||||||
|
StartURL string `json:"start_url"`
|
||||||
|
Display string `json:"display"`
|
||||||
|
Orientation string `json:"orientation"`
|
||||||
|
BackgroundColor string `json:"background_color"`
|
||||||
|
ThemeColor string `json:"theme_color"`
|
||||||
|
}{
|
||||||
|
Name: name,
|
||||||
|
ShortName: shortName,
|
||||||
|
StartURL: start,
|
||||||
|
Display: "standalone",
|
||||||
|
Orientation: "any",
|
||||||
|
BackgroundColor: "#1a1a2e",
|
||||||
|
ThemeColor: "#1a1a2e",
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
// A struct of plain strings cannot fail to marshal.
|
||||||
|
return []byte(`{"start_url":"/","display":"standalone"}`)
|
||||||
|
}
|
||||||
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -290,6 +340,68 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write(s.manifestJSON())
|
_, _ = w.Write(s.manifestJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// displayName returns the product title shown in the UI: the configured brand
|
||||||
|
// name when set, otherwise the official "SimpleRemoter".
|
||||||
|
func (s *Server) displayName() string {
|
||||||
|
if s.brandName != "" {
|
||||||
|
return s.brandName
|
||||||
|
}
|
||||||
|
return "SimpleRemoter"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWhiteLabel reports whether the web UI is mounted under another domain's
|
||||||
|
// subpath (BasePath non-empty). In that case the official SimpleRemoter
|
||||||
|
// branding (security notice, website links) is stripped and the configured
|
||||||
|
// brand name is shown instead.
|
||||||
|
func (s *Server) isWhiteLabel() bool {
|
||||||
|
return s.basePath != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBrandName picks the effective white-label title: the configured value
|
||||||
|
// when non-empty, else a fallback derived from the base path's last segment
|
||||||
|
// (e.g. "/remoter" → "Remoter"). Empty base path means the UI is served at the
|
||||||
|
// domain root, so it returns "" (official "SimpleRemoter") and ignores any
|
||||||
|
// configured name — white-label branding only applies to subpath deployment.
|
||||||
|
func resolveBrandName(configured, basePath string) string {
|
||||||
|
if basePath == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if configured != "" {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
seg := strings.Trim(basePath, "/")
|
||||||
|
if i := strings.LastIndex(seg, "/"); i >= 0 {
|
||||||
|
seg = seg[i+1:]
|
||||||
|
}
|
||||||
|
return capitalizeFirst(seg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// capitalizeFirst upper-cases the first rune, UTF-8 safe (no byte slicing).
|
||||||
|
func capitalizeFirst(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
r, size := utf8.DecodeRuneInString(s)
|
||||||
|
return string(unicode.ToUpper(r)) + s[size:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripBrandBlock removes the index.html block delimited by
|
||||||
|
// <!--BRAND-<name>-START--> … <!--BRAND-<name>-END--> (markers included).
|
||||||
|
func stripBrandBlock(body []byte, name string) []byte {
|
||||||
|
start := []byte("<!--BRAND-" + name + "-START-->")
|
||||||
|
end := []byte("<!--BRAND-" + name + "-END-->")
|
||||||
|
i := bytes.Index(body, start)
|
||||||
|
if i < 0 {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
j := bytes.Index(body[i:], end)
|
||||||
|
if j < 0 {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
j += i + len(end)
|
||||||
|
return append(body[:i], body[j:]...)
|
||||||
|
}
|
||||||
|
|
||||||
// staticHandler returns an http.HandlerFunc that serves a fixed byte slice
|
// staticHandler returns an http.HandlerFunc that serves a fixed byte slice
|
||||||
// with the given content-type. Used for embedded third-party assets (xterm.js etc.)
|
// with the given content-type. Used for embedded third-party assets (xterm.js etc.)
|
||||||
// that change infrequently — 1-day browser cache is fine.
|
// that change infrequently — 1-day browser cache is fine.
|
||||||
|
|||||||
+23
-2
@@ -33,26 +33,47 @@ func (s *Server) handleSSO(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
username, role, ok := s.sso.Detect(r)
|
username, role, ok := s.sso.Detect(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
// No usable portal credential: the normal fallback to the login form.
|
||||||
|
// Not audited — portal users without YAMA access hit this routinely.
|
||||||
s.unauthorized(w)
|
s.unauthorized(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Only the "admin" role may skip the login form via SSO. Every other role
|
// Only the "admin" role may skip the login form via SSO. Every other role
|
||||||
// (viewer, unknown) must authenticate against YAMA's own login, even with
|
// (viewer, unknown) must authenticate against YAMA's own login, even with
|
||||||
// a valid portal session.
|
// a valid portal session. A valid portal identity being refused here is
|
||||||
|
// worth an audit record: it is an authorization decision, not a login-form
|
||||||
|
// fallback.
|
||||||
if role != "admin" {
|
if role != "admin" {
|
||||||
|
fields := requestFields(r, s.trustForwardedFor)
|
||||||
|
fields["method"] = "sso"
|
||||||
|
fields["actor"] = username
|
||||||
|
fields["role"] = role
|
||||||
|
fields["reason"] = "role_not_allowed"
|
||||||
|
s.log.AuditWarn(auditLoginFail, fields)
|
||||||
s.unauthorized(w)
|
s.unauthorized(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := s.auth.IssueToken(username, role)
|
token, err := s.auth.IssueToken(username, role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fields := requestFields(r, s.trustForwardedFor)
|
||||||
|
fields["method"] = "sso"
|
||||||
|
fields["actor"] = username
|
||||||
|
fields["role"] = role
|
||||||
|
fields["reason"] = "token_issue_failed"
|
||||||
|
fields["error"] = err.Error()
|
||||||
|
s.log.AuditWarn(auditLoginFail, fields)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = w.Write([]byte(`{"error":"internal error"}`))
|
_, _ = w.Write([]byte(`{"error":"internal error"}`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("sso: user=%s role=%s", username, role)
|
fields := requestFields(r, s.trustForwardedFor)
|
||||||
|
fields["method"] = "sso"
|
||||||
|
fields["actor"] = username
|
||||||
|
fields["role"] = role
|
||||||
|
s.log.Audit(auditLoginOK, fields)
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
|||||||
@@ -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"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||||
@@ -47,13 +48,50 @@ type wsClient struct {
|
|||||||
closed chan struct{}
|
closed chan struct{}
|
||||||
once sync.Once
|
once sync.Once
|
||||||
|
|
||||||
|
// Immutable connection metadata (set once in serve).
|
||||||
|
id string // short random id for audit correlation
|
||||||
|
addr string // resolved client IP for logs / rate limits
|
||||||
|
peerAddr string // actual TCP peer (differs from addr behind a proxy)
|
||||||
|
origin string
|
||||||
|
userAgent string
|
||||||
|
connectedAt time.Time
|
||||||
|
|
||||||
// Mutated under wsHub.mu (or only by the read loop owning this client).
|
// Mutated under wsHub.mu (or only by the read loop owning this client).
|
||||||
nonce string // outstanding challenge — cleared after a successful login
|
nonce string // outstanding challenge — cleared after a successful login
|
||||||
token string // set once authenticated
|
token string // set once authenticated
|
||||||
role string // mirrors session role after login
|
role string // mirrors session role after login
|
||||||
addr string // client address for logs
|
user string // mirrors session username after login/resume
|
||||||
watching string // device ID this browser is currently streaming, "" when on the list
|
|
||||||
termWatching string // device ID for an open web terminal session, "" otherwise
|
termWatching string // device ID for an open web terminal session, "" otherwise
|
||||||
|
|
||||||
|
// watchIDs is the set of devices this browser is streaming (the screen
|
||||||
|
// 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
|
// 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 -------------------------
|
// ----- ws hub: registry of all connected browsers -------------------------
|
||||||
|
|
||||||
type wsHub struct {
|
type wsHub struct {
|
||||||
auth *wsauth.Authenticator
|
auth *wsauth.Authenticator
|
||||||
devices *hub.Hub
|
devices *hub.Hub
|
||||||
|
history *devicehistory.Store // nil → device_list stays online-only
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -112,11 +169,12 @@ type wsHub struct {
|
|||||||
trustForwardedFor bool // honor X-Forwarded-For (only when behind a trusted proxy)
|
trustForwardedFor bool // honor X-Forwarded-For (only when behind a trusted proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, log *logger.Logger) *wsHub {
|
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, history *devicehistory.Store, log *logger.Logger) *wsHub {
|
||||||
h := &wsHub{
|
h := &wsHub{
|
||||||
auth: auth,
|
auth: auth,
|
||||||
devices: devices,
|
devices: devices,
|
||||||
log: log,
|
history: history,
|
||||||
|
log: log,
|
||||||
clients: make(map[*wsClient]struct{}),
|
clients: make(map[*wsClient]struct{}),
|
||||||
screenCloseTimers: make(map[string]*time.Timer),
|
screenCloseTimers: make(map[string]*time.Timer),
|
||||||
}
|
}
|
||||||
@@ -253,12 +311,38 @@ func (h *wsHub) OnDeviceOffline(id string) {
|
|||||||
"cmd": "device_offline",
|
"cmd": "device_offline",
|
||||||
"id": id,
|
"id": id,
|
||||||
})
|
})
|
||||||
h.mu.RLock()
|
// Drop the id from every watch set as well: the device is gone, so this
|
||||||
defer h.mu.RUnlock()
|
// 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 {
|
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)
|
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) {
|
func (h *wsHub) OnCursorChange(deviceID string, index byte) {
|
||||||
msg := mustJSON(map[string]any{
|
msg := mustJSON(map[string]any{
|
||||||
"cmd": "cursor",
|
"cmd": "cursor",
|
||||||
|
"id": deviceID,
|
||||||
"index": index,
|
"index": index,
|
||||||
})
|
})
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
for c := range h.clients {
|
for c := range h.clients {
|
||||||
if c.watching == deviceID && c.token != "" {
|
if c.isWatching(deviceID) && c.token != "" {
|
||||||
c.queue(msg)
|
c.queue(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,7 +379,24 @@ func (h *wsHub) OnResolutionChange(deviceID string, width, height int) {
|
|||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
for c := range h.clients {
|
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)
|
c.queue(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,7 +410,7 @@ func (h *wsHub) OnScreenFrame(deviceID string, packet []byte, _ bool) {
|
|||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
for c := range h.clients {
|
for c := range h.clients {
|
||||||
if c.watching == deviceID && c.token != "" {
|
if c.isWatching(deviceID) && c.token != "" {
|
||||||
c.queueBinary(packet)
|
c.queueBinary(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,14 +461,35 @@ func (h *wsHub) OnTerminalClosed(deviceID string, reason string) {
|
|||||||
"ok": true,
|
"ok": true,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
})
|
})
|
||||||
|
type closed struct {
|
||||||
|
c *wsClient
|
||||||
|
start time.Time
|
||||||
|
why string
|
||||||
|
}
|
||||||
|
var done []closed
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
|
||||||
for c := range h.clients {
|
for c := range h.clients {
|
||||||
if c.termWatching == deviceID && c.token != "" {
|
if c.termWatching == deviceID && c.token != "" {
|
||||||
|
why := c.termCloseReason
|
||||||
|
if why == "" {
|
||||||
|
why = reason
|
||||||
|
}
|
||||||
|
done = append(done, closed{c, c.termStartedAt, why})
|
||||||
c.termWatching = ""
|
c.termWatching = ""
|
||||||
|
c.termStartedAt = time.Time{}
|
||||||
|
c.termCloseReason = ""
|
||||||
c.queue(msg)
|
c.queue(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
// Audit outside h.mu: h.audit takes h.mu.RLock.
|
||||||
|
for _, d := range done {
|
||||||
|
h.audit(d.c, false, auditTermStop, map[string]any{
|
||||||
|
"device_id": deviceID,
|
||||||
|
"duration_ms": durationMs(d.start),
|
||||||
|
"reason": d.why,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnDeviceUpdate forwards heartbeat-derived liveness data so the device-list
|
// OnDeviceUpdate forwards heartbeat-derived liveness data so the device-list
|
||||||
@@ -407,20 +530,53 @@ func (h *wsHub) register(c *wsClient) {
|
|||||||
func (h *wsHub) unregister(c *wsClient) {
|
func (h *wsHub) unregister(c *wsClient) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
delete(h.clients, c)
|
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()
|
h.mu.Unlock()
|
||||||
|
|
||||||
// If this client was the last viewer of a device, tear down the screen
|
// If this client was the last viewer of a device, tear down the screen
|
||||||
// session so the device stops encoding. Done OUTSIDE the lock so the
|
// session so the device stops encoding.
|
||||||
// hub's mutators can take their own locks without risk of recursion.
|
for _, id := range watched {
|
||||||
if c.watching != "" && h.countWatchers(c.watching) == 0 {
|
if !started[id].IsZero() {
|
||||||
h.deferredCloseScreen(c.watching)
|
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
|
// Terminal sessions are single-viewer by design, so any open session
|
||||||
// belongs to this client. Tear it down so the next viewer doesn't
|
// belongs to this client. Tear it down so the next viewer doesn't
|
||||||
// hit ErrTerminalBusy from an abandoned session.
|
// hit ErrTerminalBusy from an abandoned session. The client is already
|
||||||
if c.termWatching != "" {
|
// out of h.clients, so OnTerminalClosed won't reach it — audit here.
|
||||||
h.devices.CloseTerminalSession(c.termWatching)
|
if termDevice != "" {
|
||||||
c.termWatching = ""
|
h.devices.CloseTerminalSession(termDevice)
|
||||||
|
h.audit(c, false, auditTermStop, map[string]any{
|
||||||
|
"device_id": termDevice,
|
||||||
|
"duration_ms": durationMs(termStart),
|
||||||
|
"reason": "ws_close",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
h.auditClose(c, len(watched))
|
||||||
// Do NOT revoke the token: tokens are session-scoped, not WS-scoped.
|
// Do NOT revoke the token: tokens are session-scoped, not WS-scoped.
|
||||||
// Frontend may close+reopen the WS at any time (visibilitychange handler,
|
// Frontend may close+reopen the WS at any time (visibilitychange handler,
|
||||||
// brief network blip, reload) and must be able to resume with the same
|
// brief network blip, reload) and must be able to resume with the same
|
||||||
@@ -455,6 +611,16 @@ func (h *wsHub) deferredCloseScreen(deviceID string) {
|
|||||||
// ----- HTTP handler -------------------------------------------------------
|
// ----- HTTP handler -------------------------------------------------------
|
||||||
|
|
||||||
func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
|
func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Reject disallowed origins explicitly so the rejection is auditable.
|
||||||
|
// Upgrader.CheckOrigin below is kept as defense in depth.
|
||||||
|
if !h.checkOrigin(r) {
|
||||||
|
fields := requestFields(r, h.trustForwardedFor)
|
||||||
|
fields["reason"] = "origin_not_allowed"
|
||||||
|
h.log.AuditWarn(auditWSReject, fields)
|
||||||
|
http.Error(w, "origin not allowed", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Build a per-call upgrader so CheckOrigin closes over this hub's
|
// Build a per-call upgrader so CheckOrigin closes over this hub's
|
||||||
// allowlist instead of a package-level mutable.
|
// allowlist instead of a package-level mutable.
|
||||||
upgrader := baseUpgrader
|
upgrader := baseUpgrader
|
||||||
@@ -462,7 +628,10 @@ func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("ws upgrade: %v", err)
|
fields := requestFields(r, h.trustForwardedFor)
|
||||||
|
fields["reason"] = "upgrade_failed"
|
||||||
|
fields["error"] = err.Error()
|
||||||
|
h.log.AuditWarn(auditWSReject, fields)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
conn.SetReadLimit(wsReadLimit)
|
conn.SetReadLimit(wsReadLimit)
|
||||||
@@ -475,19 +644,28 @@ func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := &wsClient{
|
client := &wsClient{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
send: make(chan wsMsg, wsSendBuffer),
|
send: make(chan wsMsg, wsSendBuffer),
|
||||||
closed: make(chan struct{}),
|
closed: make(chan struct{}),
|
||||||
nonce: nonce,
|
id: newConnID(),
|
||||||
addr: clientIP(r, h.trustForwardedFor),
|
addr: clientIP(r, h.trustForwardedFor),
|
||||||
|
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.register(client)
|
||||||
|
h.audit(client, false, auditWSConnect, client.wsRequestFields())
|
||||||
defer h.unregister(client)
|
defer h.unregister(client)
|
||||||
|
|
||||||
go h.writeLoop(client)
|
go h.writeLoop(client)
|
||||||
|
|
||||||
// Greet with a challenge nonce so the browser can compute the login response.
|
// 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)
|
h.readLoop(client)
|
||||||
}
|
}
|
||||||
|
|||||||
+690
-71
File diff suppressed because it is too large.
Load diff
+2525
-92
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user