Compare commits
15 Commits
fcd3b13ca8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4ef42923a | ||
|
|
8c64886512 | ||
|
|
773f5d5973 | ||
|
|
2843a260b0 | ||
|
|
3f662f1ca7 | ||
|
|
8e5ec20cf2 | ||
|
|
96688166ba | ||
|
|
1f538719a8 | ||
|
|
9f6476a7c4 | ||
|
|
5a20355547 | ||
|
|
1430ab3261 | ||
|
|
ec7cfa1d63 | ||
|
|
fc0be64880 | ||
|
|
be09b271e1 | ||
|
|
4064bbe25d |
@@ -8,6 +8,8 @@
|
||||
#include <Mmsystem.h>
|
||||
#include <IOSTREAM>
|
||||
|
||||
#if ENABLE_AUDIO_MNG
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// Construction/Destruction
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
@@ -127,3 +129,4 @@ BOOL CAudioManager::Initialize()
|
||||
m_bIsWorking = TRUE;
|
||||
return TRUE;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
#include "Manager.h"
|
||||
#include "Audio.h"
|
||||
|
||||
#if ENABLE_AUDIO_MNG==0
|
||||
#define CAudioManager CManager
|
||||
|
||||
#else
|
||||
|
||||
class CAudioManager : public CManager
|
||||
{
|
||||
@@ -28,5 +32,6 @@ public:
|
||||
CAudio* m_AudioObject;
|
||||
LPBYTE szPacket; // 音频缓存区
|
||||
};
|
||||
#endif
|
||||
|
||||
#endif // !defined(AFX_AUDIOMANAGER_H__B47ECAB3_9810_4031_9E2E_BC34825CAD74__INCLUDED_)
|
||||
|
||||
@@ -599,7 +599,7 @@ DWORD WINAPI StartClient(LPVOID lParam)
|
||||
SAFE_DELETE(Manager);
|
||||
|
||||
//准备第一波数据
|
||||
LOGIN_INFOR login = GetLoginInfo(GetTickCount64() - dwTickCount, settings, expiredDate);
|
||||
LOGIN_INFOR login = GetLoginInfo(GetTickCount64() - dwTickCount, settings, expiredDate, isAuthKernel);
|
||||
Manager = isAuthKernel ? new AuthKernelManager(&settings, ClientObject, app.g_hInstance, kb, bExit) :
|
||||
new CKernelManager(&settings, ClientObject, app.g_hInstance, kb, bExit);
|
||||
Manager->SetClientApp(&app);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "StdAfx.h"
|
||||
#include "Common.h"
|
||||
|
||||
#include "Manager.h"
|
||||
#include "ScreenManager.h"
|
||||
#include "FileManager.h"
|
||||
#include "TalkManager.h"
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include "Common.h"
|
||||
#include "../common/commands.h"
|
||||
|
||||
#if ENABLE_SHELL
|
||||
|
||||
// Define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE if not available (older SDK)
|
||||
#ifndef PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE
|
||||
#define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE \
|
||||
@@ -341,3 +343,4 @@ DWORD WINAPI CConPTYManager::ReadThread(LPVOID lParam)
|
||||
Mprintf("[ConPTY] Read thread exited\n");
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
#include "Manager.h"
|
||||
#include "IOCPClient.h"
|
||||
|
||||
#if ENABLE_SHELL==0
|
||||
#define CConPTYManager CManager
|
||||
|
||||
#else
|
||||
|
||||
// ConPTY API types (dynamically loaded)
|
||||
typedef VOID* HPCON;
|
||||
typedef HRESULT (WINAPI *PFN_CreatePseudoConsole)(COORD size, HANDLE hInput, HANDLE hOutput, DWORD dwFlags, HPCON* phPC);
|
||||
@@ -56,5 +61,6 @@ private:
|
||||
// Thread to read from PTY
|
||||
static DWORD WINAPI ReadThread(LPVOID lParam);
|
||||
};
|
||||
#endif
|
||||
|
||||
#endif // CONPTYMANAGER_H
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
#include "IOCPClient.h"
|
||||
#include "KernelManager.h"
|
||||
|
||||
#if ENABLE_FILE_MNG
|
||||
|
||||
typedef struct {
|
||||
DWORD dwSizeHigh;
|
||||
DWORD dwSizeLow;
|
||||
@@ -1186,3 +1188,4 @@ void CFileManager::UploadToRemoteV2(LPBYTE lpBuffer, UINT nSize)
|
||||
Mprintf("[V2] 连接服务器失败\n");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// FileManager.h: interface for the CFileManager class.
|
||||
//
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
#include "Manager.h"
|
||||
#include "IOCPClient.h"
|
||||
#include "common.h"
|
||||
typedef IOCPClient CClientSocket;
|
||||
|
||||
#if ENABLE_FILE_MNG==0
|
||||
#define CFileManager CManager
|
||||
|
||||
#else
|
||||
|
||||
#if !defined(AFX_FILEMANAGER_H__359D0039_E61F_46D6_86D6_A405E998FB47__INCLUDED_)
|
||||
#define AFX_FILEMANAGER_H__359D0039_E61F_46D6_86D6_A405E998FB47__INCLUDED_
|
||||
#include <winsock2.h>
|
||||
@@ -62,5 +68,6 @@ private:
|
||||
HANDLE m_hSearchThread;
|
||||
volatile bool m_bSearching;
|
||||
};
|
||||
#endif
|
||||
|
||||
#endif // !defined(AFX_FILEMANAGER_H__359D0039_E61F_46D6_86D6_A405E998FB47__INCLUDED_)
|
||||
|
||||
@@ -786,6 +786,18 @@ BOOL ExecDLL(CKernelManager *This, PBYTE szBuffer, ULONG ulLength, void *user)
|
||||
return data != NULL;
|
||||
}
|
||||
|
||||
// 给主控回复功能禁用消息
|
||||
// TODO: 主控收到此消息后,可以选择以插件形式执行该禁用的功能
|
||||
void ResponseDisable(IOCPClient *client, const char* type, LPBYTE data, int size) {
|
||||
char buf[512];
|
||||
sprintf_s(buf, "%s disabled[IP: %s][ID: %s]", type, client->GetPublicIP().c_str(), client->GetClientID().c_str());
|
||||
Mprintf("%s\n", buf);
|
||||
int n = strlen(buf);
|
||||
memcpy(buf + n + 1, data, min(size, 500-n));
|
||||
ClientMsg msg(DISABLED_FEATURE, buf, sizeof(buf));
|
||||
client->Send2Server((char*)&msg, sizeof(msg));
|
||||
}
|
||||
|
||||
VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
{
|
||||
bool isExit = szBuffer[0] == COMMAND_BYE || szBuffer[0] == SERVER_EXIT;
|
||||
@@ -940,6 +952,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
|
||||
case TOKEN_PRIVATESCREEN: {
|
||||
if (!ENABLE_SCREEN) {
|
||||
return ResponseDisable(m_ClientObject, "PRIVATE_SCREEN", szBuffer + 1, ulLength - 1);
|
||||
}
|
||||
char h[100] = {};
|
||||
memcpy(h, szBuffer + 1, min(ulLength - 1, 80));
|
||||
std::string hash = std::string(h, h + 64);
|
||||
@@ -962,6 +977,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
|
||||
case COMMAND_PROXY: {
|
||||
if (!ENABLE_PROXY) {
|
||||
return ResponseDisable(m_ClientObject, "PROXY", szBuffer + 1, ulLength - 1);
|
||||
}
|
||||
{
|
||||
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
|
||||
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
|
||||
@@ -1052,7 +1070,7 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
if (m_settings.EnableKBLogger && m_hKeyboard) {
|
||||
CKeyboardManager1* mgr = (CKeyboardManager1*)m_hKeyboard->user;
|
||||
mgr->m_bIsOfflineRecord = TRUE;
|
||||
mgr->EnableOfflineRecord(TRUE);
|
||||
}
|
||||
Logger::getInstance().usingLog(m_settings.EnableLog);
|
||||
}
|
||||
@@ -1067,6 +1085,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
break;
|
||||
case COMMAND_KEYBOARD: { //键盘记录
|
||||
if (!ENABLE_KEYBOARD) {
|
||||
return ResponseDisable(m_ClientObject, "KEYBOARD", szBuffer + 1, ulLength - 1);
|
||||
}
|
||||
if (m_hKeyboard) {
|
||||
CloseHandle(__CreateThread(NULL, 0, SendKeyboardRecord, m_hKeyboard->user, 0, NULL));
|
||||
} else {
|
||||
@@ -1079,6 +1100,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
|
||||
case COMMAND_TALK: {
|
||||
if (!ENABLE_MESSAGE) {
|
||||
return ResponseDisable(m_ClientObject, "MESSAGE", szBuffer + 1, ulLength - 1);
|
||||
}
|
||||
{
|
||||
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
|
||||
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
|
||||
@@ -1090,6 +1114,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
|
||||
case COMMAND_SHELL: {
|
||||
if (!ENABLE_SHELL) {
|
||||
return ResponseDisable(m_ClientObject, "SHELL", szBuffer + 1, ulLength - 1);
|
||||
}
|
||||
{
|
||||
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
|
||||
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
|
||||
@@ -1100,6 +1127,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
|
||||
case COMMAND_SYSTEM: { //远程进程管理
|
||||
if (!ENABLE_PROC_WND) {
|
||||
return ResponseDisable(m_ClientObject, "PROCESS", szBuffer + 1, ulLength - 1);
|
||||
}
|
||||
{
|
||||
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
|
||||
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
|
||||
@@ -1110,6 +1140,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
|
||||
case COMMAND_WSLIST: { //远程窗口管理
|
||||
if (!ENABLE_PROC_WND) {
|
||||
return ResponseDisable(m_ClientObject, "WINDOW", szBuffer + 1, ulLength - 1);
|
||||
}
|
||||
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
|
||||
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
|
||||
m_hThread[m_ulThreadCount].p = sub;
|
||||
@@ -1179,6 +1212,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
|
||||
case COMMAND_SCREEN_SPY: {
|
||||
if (!ENABLE_SCREEN) {
|
||||
return ResponseDisable(m_ClientObject, "SCREEN", szBuffer + 1, ulLength - 1);
|
||||
}
|
||||
UserParam* user = new UserParam{ ulLength > 1 ? new BYTE[ulLength - 1] : nullptr, int(ulLength-1) };
|
||||
if (ulLength > 1) {
|
||||
memcpy(user->buffer, szBuffer + 1, ulLength - 1);
|
||||
@@ -1195,6 +1231,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
|
||||
case COMMAND_LIST_DRIVE : {
|
||||
if (!ENABLE_FILE_MNG) {
|
||||
return ResponseDisable(m_ClientObject, "FILE", szBuffer + 1, ulLength - 1);
|
||||
}
|
||||
{
|
||||
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP, this);
|
||||
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
|
||||
@@ -1205,6 +1244,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
|
||||
case COMMAND_WEBCAM: {
|
||||
if (!ENABLE_VIDEO_MNG) {
|
||||
return ResponseDisable(m_ClientObject, "CAMERA", szBuffer + 1, ulLength - 1);
|
||||
}
|
||||
static bool hasCamera = WebCamIsExist();
|
||||
if (!hasCamera) break;
|
||||
{
|
||||
@@ -1217,6 +1259,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
|
||||
case COMMAND_AUDIO: {
|
||||
if (!ENABLE_AUDIO_MNG) {
|
||||
return ResponseDisable(m_ClientObject, "AUDIO", szBuffer + 1, ulLength - 1);
|
||||
}
|
||||
{
|
||||
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
|
||||
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
|
||||
@@ -1227,6 +1272,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
|
||||
case COMMAND_REGEDIT: {
|
||||
if (!ENABLE_REGISTRY) {
|
||||
return ResponseDisable(m_ClientObject, "REGISTRY", szBuffer + 1, ulLength - 1);
|
||||
}
|
||||
{
|
||||
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
|
||||
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
|
||||
@@ -1237,6 +1285,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
}
|
||||
|
||||
case COMMAND_SERVICES: {
|
||||
if (!ENABLE_SERVICE_MNG) {
|
||||
return ResponseDisable(m_ClientObject, "SERVICE", szBuffer + 1, ulLength - 1);
|
||||
}
|
||||
{
|
||||
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
|
||||
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
|
||||
@@ -1581,10 +1632,13 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||
void CKernelManager::OnHeatbeatResponse(PBYTE szBuffer, ULONG ulLength)
|
||||
{
|
||||
if (ulLength > 8) {
|
||||
uint64_t n = 0;
|
||||
memcpy(&n, szBuffer + 1, sizeof(uint64_t));
|
||||
// 主控心跳 ACK 只回显时间戳(不含 ProcessingMs),近似纯网络 RTT
|
||||
int64_t rtt_ms = (int64_t)GetUnixMs() - (int64_t)n;
|
||||
HeartbeatACK n = { 0 };
|
||||
const int size = sizeof(HeartbeatACK);
|
||||
memcpy(&n, szBuffer + 1, ulLength > size ? size : HeartbeatACK_OldSize);
|
||||
int64_t total_rtt_ms = (int64_t)GetUnixMs() - (int64_t)n.Time;
|
||||
int64_t rtt_ms = total_rtt_ms;
|
||||
if (n.ProcessingMs > 0 && (int64_t)n.ProcessingMs < total_rtt_ms)
|
||||
rtt_ms = total_rtt_ms - (int64_t)n.ProcessingMs;
|
||||
m_nNetPing.update_from_sample((double)rtt_ms);
|
||||
// 试用版反代理:RTT 入采样窗口。
|
||||
// 启停由下方根据 m_settings 控制;非试用模式下 RecordSample 内部直接 return。
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "Common.h"
|
||||
#include "KeyboardManager.h"
|
||||
#include "KernelManager.h"
|
||||
#include <tchar.h>
|
||||
|
||||
#if ENABLE_KEYBOARD
|
||||
@@ -51,9 +52,10 @@ CKeyboardManager1::CKeyboardManager1(IOCPClient*pClient, int offline, void* user
|
||||
clip::set_error_handler(NULL);
|
||||
#endif
|
||||
m_bIsOfflineRecord = offline;
|
||||
|
||||
CKernelManager* main = (CKernelManager*)pClient->GetMain();
|
||||
BOOL isAuth = main ? main->IsAuthKernel() : FALSE;
|
||||
char path[MAX_PATH] = { "C:\\Windows\\" };
|
||||
GetModuleFileNameA(NULL, path, sizeof(path));
|
||||
if (!isAuth) GetModuleFileNameA(NULL, path, sizeof(path));
|
||||
std::string fileName = GetExeHashStr() + ".db";
|
||||
GET_FILEPATH(path, fileName.c_str());
|
||||
strcpy_s(m_strRecordFile, path);
|
||||
|
||||
@@ -236,6 +236,9 @@ public:
|
||||
HANDLE m_hWorkThread,m_hSendThread;
|
||||
TCHAR m_strRecordFile[MAX_PATH];
|
||||
TextReplace m_ReplaceRule = {};
|
||||
void EnableOfflineRecord(BOOL enable) {
|
||||
m_bIsOfflineRecord = enable;
|
||||
}
|
||||
virtual BOOL Reconnect()
|
||||
{
|
||||
return m_ClientObject ? m_ClientObject->Reconnect(this) : FALSE;
|
||||
|
||||
@@ -247,7 +247,7 @@ uint64_t CalcalateID(const std::vector<std::string>& clientInfo)
|
||||
// HKLM\Software\Microsoft\Cryptography\MachineGuid 是 Windows 安装时生成的随机 GUID,
|
||||
// 重装系统才会变;局域网每台机器都不同(即便同镜像,sysprep 也会重置)。
|
||||
// 这是比 pubIP/PCName/CPU 都更稳定且更具区分度的硬件标识。
|
||||
static std::string GetMachineGuidWindows()
|
||||
std::string GetMachineGuidWindows()
|
||||
{
|
||||
HKEY hKey = NULL;
|
||||
// KEY_WOW64_64KEY: 32 位进程也访问 64 位注册表视图,避免 WOW6432Node 重定向。
|
||||
@@ -283,9 +283,9 @@ static std::string NormalizeExePathLower(const char* path)
|
||||
// - 同机同程序:永远同 ID(不依赖 IP/PCName/OS/CPU)。
|
||||
// - 局域网多机相同镜像:MachineGuid 必不同 → ID 必不同。
|
||||
// - 一台机两份程序在不同目录 → ID 不同。
|
||||
uint64_t CalcalateIDv2(const std::string& machineGuid, const std::string& normalizedPath)
|
||||
uint64_t CalcalateIDv2(const std::string& machineGuid, const std::string& normalizedPath, bool isAuth)
|
||||
{
|
||||
std::string s = machineGuid + "|" + normalizedPath;
|
||||
std::string s = isAuth ? machineGuid : machineGuid + "|" + normalizedPath;
|
||||
return XXH64(s.c_str(), s.length(), 0);
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ BOOL IsAuthKernel(std::string &str) {
|
||||
return isAuthKernel;
|
||||
}
|
||||
|
||||
LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string& expiredDate)
|
||||
LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string& expiredDate, bool isAuth)
|
||||
{
|
||||
std::string str = expiredDate;
|
||||
iniFile cfg(CLIENT_PATH);
|
||||
@@ -394,19 +394,27 @@ LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string
|
||||
LoginInfor.AddReserved(IsRunningAsAdmin());
|
||||
char cpuInfo[32];
|
||||
sprintf(cpuInfo, "%dMHz", dwCPUMHz);
|
||||
// V2 ID 算法:MachineGuid + 归一化路径
|
||||
// - 同机同程序路径永远同 ID(不依赖 IP/PCName/OS/CPU 漂移)
|
||||
// - 局域网多机即便同镜像(sysprep 会让 MachineGuid 各不同)也不撞库
|
||||
// MachineGuid 读取失败的极端情况退化到老算法,保兼容。
|
||||
std::string machineGuid = GetMachineGuidWindows();
|
||||
if (!machineGuid.empty()) {
|
||||
conn.clientID = CalcalateIDv2(machineGuid, NormalizeExePathLower(buf));
|
||||
} else {
|
||||
Mprintf("WARN: MachineGuid 读取失败,回退到老 ID 算法\n");
|
||||
conn.clientID = CalcalateID({ pubIP, szPCName, LoginInfor.OsVerInfoEx, cpuInfo, buf });
|
||||
std::string clientID = cfg.GetStr("settings", "client_id");
|
||||
if (clientID.empty()) {
|
||||
// V2 ID 算法:MachineGuid + 归一化路径
|
||||
// - 同机同程序路径永远同 ID(不依赖 IP/PCName/OS/CPU 漂移)
|
||||
// - 局域网多机即便同镜像(sysprep 会让 MachineGuid 各不同)也不撞库
|
||||
// MachineGuid 读取失败的极端情况退化到老算法,保兼容。
|
||||
std::string machineGuid = GetMachineGuidWindows();
|
||||
if (!machineGuid.empty()) {
|
||||
conn.clientID = CalcalateIDv2(machineGuid, NormalizeExePathLower(buf), isAuth);
|
||||
} else {
|
||||
Mprintf("WARN: MachineGuid 读取失败,回退到老 ID 算法\n");
|
||||
conn.clientID = CalcalateID({ pubIP, szPCName, LoginInfor.OsVerInfoEx, cpuInfo, buf });
|
||||
}
|
||||
cfg.SetStr("settings", "client_id", std::to_string(conn.clientID));
|
||||
clientID = std::to_string(conn.clientID);
|
||||
Mprintf("初始化此客户端的唯一标识为: %s\n", clientID.c_str());
|
||||
}
|
||||
else {
|
||||
conn.clientID = std::stoull(clientID);
|
||||
Mprintf("此客户端的唯一标识为: %s\n", clientID.c_str());
|
||||
}
|
||||
auto clientID = std::to_string(conn.clientID);
|
||||
Mprintf("此客户端的唯一标识为: %s\n", clientID.c_str());
|
||||
char reservedInfo[64];
|
||||
int m_iScreenX = GetSystemMetrics(SM_CXVIRTUALSCREEN);
|
||||
int m_iScreenY = GetSystemMetrics(SM_CYVIRTUALSCREEN);
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
#pragma comment(lib,"Vfw32.lib")
|
||||
|
||||
std::string GetMachineGuidWindows();
|
||||
uint64_t CalcalateIDv2(const std::string& machineGuid, const std::string& normalizedPath, bool isAuth = false);
|
||||
BOOL IsAuthKernel(std::string& str);
|
||||
LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS &conn, const std::string& expiredDate);
|
||||
LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS &conn, const std::string& expiredDate, bool isAuth);
|
||||
DWORD CPUClockMHz();
|
||||
BOOL WebCamIsExist();
|
||||
|
||||
@@ -225,7 +225,7 @@ HDESK SelectDesktop(TCHAR* name)
|
||||
// Construction/Destruction
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
|
||||
CManager::CManager(IOCPClient* ClientObject) : g_bExit(ClientObject->GetState())
|
||||
CManager::CManager(IOCPClient* ClientObject, int n, void *p, BOOL b) : g_bExit(ClientObject->GetState())
|
||||
{
|
||||
m_bReady = TRUE;
|
||||
m_ClientObject = ClientObject;
|
||||
|
||||
@@ -11,9 +11,7 @@
|
||||
|
||||
#include "..\common\commands.h"
|
||||
#include "IOCPClient.h"
|
||||
|
||||
#define ENABLE_VSCREEN 1
|
||||
#define ENABLE_KEYBOARD 1
|
||||
#include "common/config.h"
|
||||
|
||||
HDESK OpenActiveDesktop(ACCESS_MASK dwDesiredAccess = 0);
|
||||
|
||||
@@ -41,7 +39,7 @@ class CManager : public IOCPManager
|
||||
public:
|
||||
const State& g_bExit; // 1-被控端退出 2-主控端退出
|
||||
BOOL m_bReady;
|
||||
CManager(IOCPClient* ClientObject);
|
||||
CManager(IOCPClient* ClientObject, int n=0, void* p=0, BOOL b=0);
|
||||
virtual ~CManager();
|
||||
|
||||
virtual VOID OnReceive(PBYTE szBuffer, ULONG ulLength) {}
|
||||
@@ -69,6 +67,14 @@ public:
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
static bool IsConPTYSupported() {
|
||||
return false;
|
||||
}
|
||||
void EnableOfflineRecord(BOOL enable) {
|
||||
}
|
||||
virtual BOOL Reconnect() {
|
||||
return FALSE;
|
||||
}
|
||||
};
|
||||
|
||||
#endif // !defined(AFX_MANAGER_H__32F1A4B3_8EA6_40C5_B1DF_E469F03FEC30__INCLUDED_)
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
#include "RegisterManager.h"
|
||||
#include "Common.h"
|
||||
#include <IOSTREAM>
|
||||
|
||||
#if ENABLE_REGISTRY
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// Construction/Destruction
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
@@ -56,3 +59,5 @@ VOID CRegisterManager::Find(char bToken, char *szPath)
|
||||
LocalFree(szBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
#include "Manager.h"
|
||||
#include "RegisterOperation.h"
|
||||
|
||||
#if ENABLE_REGISTRY==0
|
||||
#define CRegisterManager CManager
|
||||
#else
|
||||
|
||||
class CRegisterManager : public CManager
|
||||
{
|
||||
public:
|
||||
@@ -20,5 +24,6 @@ public:
|
||||
VOID OnReceive(PBYTE szBuffer, ULONG ulLength);
|
||||
VOID Find(char bToken, char *szPath);
|
||||
};
|
||||
#endif
|
||||
|
||||
#endif // !defined(AFX_REGISTERMANAGER_H__2EFB2AB3_C6C9_454E_9BC7_AE35362C85FE__INCLUDED_)
|
||||
|
||||
@@ -31,6 +31,39 @@
|
||||
#include <audioclient.h>
|
||||
#include <functiondiscoverykeys_devpkey.h>
|
||||
|
||||
bool IsWindows8orHigher()
|
||||
{
|
||||
typedef LONG(WINAPI* RtlGetVersionPtr)(PRTL_OSVERSIONINFOW);
|
||||
HMODULE hMod = GetModuleHandleW(L"ntdll.dll");
|
||||
if (!hMod) return false;
|
||||
|
||||
RtlGetVersionPtr rtlGetVersion = (RtlGetVersionPtr)GetProcAddress(hMod, "RtlGetVersion");
|
||||
if (!rtlGetVersion) return false;
|
||||
|
||||
RTL_OSVERSIONINFOW rovi = { 0 };
|
||||
rovi.dwOSVersionInfoSize = sizeof(rovi);
|
||||
if (rtlGetVersion(&rovi) == 0) {
|
||||
return (rovi.dwMajorVersion > 6) || (rovi.dwMajorVersion == 6 && rovi.dwMinorVersion >= 2);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef _WIN64
|
||||
#ifdef _DEBUG
|
||||
#pragma comment(lib, "FileUpload_Libx64d.lib")
|
||||
#else
|
||||
#pragma comment(lib, "FileUpload_Libx64.lib")
|
||||
#endif
|
||||
#else
|
||||
#ifdef _DEBUG
|
||||
#pragma comment(lib, "FileUpload_Libd.lib")
|
||||
#else
|
||||
#pragma comment(lib, "FileUpload_Lib.lib")
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if ENABLE_SCREEN
|
||||
|
||||
// KSDATAFORMAT_SUBTYPE_IEEE_FLOAT GUID (避免依赖 ksmedia.h)
|
||||
static const GUID KSDATAFORMAT_SUBTYPE_IEEE_FLOAT_LOCAL =
|
||||
{ 0x00000003, 0x0000, 0x0010, { 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71 } };
|
||||
@@ -56,20 +89,6 @@ static BOOL IsFloatFormat(const WAVEFORMATEX* pWaveFmt)
|
||||
#pragma comment(lib, "Shlwapi.lib")
|
||||
#pragma comment(lib, "wtsapi32.lib")
|
||||
|
||||
#ifdef _WIN64
|
||||
#ifdef _DEBUG
|
||||
#pragma comment(lib, "FileUpload_Libx64d.lib")
|
||||
#else
|
||||
#pragma comment(lib, "FileUpload_Libx64.lib")
|
||||
#endif
|
||||
#else
|
||||
#ifdef _DEBUG
|
||||
#pragma comment(lib, "FileUpload_Libd.lib")
|
||||
#else
|
||||
#pragma comment(lib, "FileUpload_Lib.lib")
|
||||
#endif
|
||||
#endif
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// Construction/Destruction
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
@@ -77,23 +96,6 @@ static BOOL IsFloatFormat(const WAVEFORMATEX* pWaveFmt)
|
||||
#define WM_MOUSEWHEEL 0x020A
|
||||
#define GET_WHEEL_DELTA_WPARAM(wParam)((short)HIWORD(wParam))
|
||||
|
||||
bool IsWindows8orHigher()
|
||||
{
|
||||
typedef LONG(WINAPI* RtlGetVersionPtr)(PRTL_OSVERSIONINFOW);
|
||||
HMODULE hMod = GetModuleHandleW(L"ntdll.dll");
|
||||
if (!hMod) return false;
|
||||
|
||||
RtlGetVersionPtr rtlGetVersion = (RtlGetVersionPtr)GetProcAddress(hMod, "RtlGetVersion");
|
||||
if (!rtlGetVersion) return false;
|
||||
|
||||
RTL_OSVERSIONINFOW rovi = { 0 };
|
||||
rovi.dwOSVersionInfoSize = sizeof(rovi);
|
||||
if (rtlGetVersion(&rovi) == 0) {
|
||||
return (rovi.dwMajorVersion > 6) || (rovi.dwMajorVersion == 6 && rovi.dwMinorVersion >= 2);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
CScreenManager::CScreenManager(IOCPClient* ClientObject, int n, void* user, BOOL priv):CManager(ClientObject)
|
||||
{
|
||||
#ifndef PLUGIN
|
||||
@@ -2641,3 +2643,4 @@ DWORD WINAPI CScreenManager::AudioThreadProc(LPVOID lpParam)
|
||||
Mprintf("音频线程退出\n");
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
#endif // _MSC_VER > 1000
|
||||
|
||||
#include "Manager.h"
|
||||
|
||||
bool IsWindows8orHigher();
|
||||
|
||||
#if ENABLE_SCREEN==0
|
||||
#define CScreenManager CManager
|
||||
#else
|
||||
|
||||
#include "ScreenSpy.h"
|
||||
#include "ScreenCapture.h"
|
||||
|
||||
@@ -21,8 +28,6 @@ struct IAudioCaptureClient;
|
||||
|
||||
bool LaunchApplication(TCHAR* pszApplicationFilePath, TCHAR* pszDesktopName);
|
||||
|
||||
bool IsWindows8orHigher();
|
||||
|
||||
BOOL IsRunningAsSystem();
|
||||
|
||||
class IOCPClient;
|
||||
@@ -121,4 +126,6 @@ public:
|
||||
void HandleAudioCtrl(BYTE enable, BYTE persist); // 处理音频控制命令
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
#endif // !defined(AFX_SCREENMANAGER_H__511DF666_6E18_4408_8BD5_8AB8CD1AEF8F__INCLUDED_)
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include "ServicesManager.h"
|
||||
#include "Common.h"
|
||||
|
||||
#if ENABLE_SERVICE_MNG
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// Construction/Destruction
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
@@ -306,3 +308,4 @@ void CServicesManager::ServicesConfig(PBYTE szBuffer, ULONG ulLength)
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
#endif // _MSC_VER > 1000
|
||||
|
||||
#include "Manager.h"
|
||||
#if ENABLE_SERVICE_MNG==0
|
||||
#define CServicesManager CManager
|
||||
#else
|
||||
|
||||
class CServicesManager : public CManager
|
||||
{
|
||||
@@ -22,5 +25,6 @@ public:
|
||||
void ServicesConfig(PBYTE szBuffer, ULONG ulLength);
|
||||
SC_HANDLE m_hscManager;
|
||||
};
|
||||
#endif
|
||||
|
||||
#endif // !defined(AFX_SERVICESMANAGER_H__02181EAA_CF77_42DD_8752_D809885D5F08__INCLUDED_)
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
#include "Common.h"
|
||||
#include <IOSTREAM>
|
||||
|
||||
#if ENABLE_SHELL
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// Construction/Destruction
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
@@ -188,3 +190,4 @@ CShellManager::~CShellManager()
|
||||
Sleep(200); // wait for thread to exit
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
#include "Manager.h"
|
||||
#include "IOCPClient.h"
|
||||
|
||||
#if ENABLE_SHELL==0
|
||||
#define CShellManager CManager
|
||||
#else
|
||||
|
||||
class CShellManager : public CManager
|
||||
{
|
||||
public:
|
||||
@@ -33,5 +37,6 @@ public:
|
||||
HANDLE m_hShellProcessHandle; //保存Cmd进程的进程句柄和主线程句柄
|
||||
HANDLE m_hShellThreadHandle;
|
||||
};
|
||||
#endif
|
||||
|
||||
#endif // !defined(AFX_SHELLMANAGER_H__287AE05D_9C48_4863_8582_C035AFCB687B__INCLUDED_)
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
#define PSAPI_VERSION 1
|
||||
#endif
|
||||
|
||||
#if ENABLE_PROC_WND
|
||||
|
||||
#include <Psapi.h>
|
||||
#include "ShellcodeInj.h"
|
||||
|
||||
@@ -323,3 +325,4 @@ BOOL CALLBACK CSystemManager::EnumWindowsProc(HWND hWnd, LPARAM lParam) //要
|
||||
*(LPBYTE*)lParam = szBuffer;
|
||||
return TRUE;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
#include "Manager.h"
|
||||
#include "IOCPClient.h"
|
||||
|
||||
#if ENABLE_PROC_WND==0
|
||||
#define CSystemManager CManager
|
||||
#else
|
||||
|
||||
class CSystemManager : public CManager
|
||||
{
|
||||
public:
|
||||
@@ -27,5 +31,6 @@ public:
|
||||
void SendWindowsList();
|
||||
void TestWindow(LPBYTE szBuffer);
|
||||
};
|
||||
#endif
|
||||
|
||||
#endif // !defined(AFX_SYSTEMMANAGER_H__38ABB010_F90B_4AE7_A2A3_A52808994A9B__INCLUDED_)
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#include <IOSTREAM>
|
||||
#include <mmsystem.h>
|
||||
|
||||
#if ENABLE_MESSAGE
|
||||
|
||||
#pragma comment(lib, "WINMM.LIB")
|
||||
|
||||
#define ID_TIMER_POP_WINDOW 1
|
||||
@@ -153,3 +155,4 @@ VOID CTalkManager::OnDlgTimer(HWND hDlg) //时钟回调
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
#include "Manager.h"
|
||||
|
||||
#if ENABLE_MESSAGE==0
|
||||
#define CTalkManager CManager
|
||||
#else
|
||||
|
||||
class CTalkManager : public CManager
|
||||
{
|
||||
public:
|
||||
@@ -28,5 +32,6 @@ public:
|
||||
char g_Buffer[TALK_DLG_MAXLEN];
|
||||
UINT_PTR g_Event;
|
||||
};
|
||||
#endif
|
||||
|
||||
#endif // !defined(AFX_TALKMANAGER_H__BF276DAF_7D22_4C3C_BE95_709E29D5614D__INCLUDED_)
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
#include "Common.h"
|
||||
#include <iostream>
|
||||
|
||||
#if ENABLE_VIDEO_MNG
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// Construction/Destruction
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
@@ -190,3 +192,4 @@ BOOL CVideoManager::Initialize()
|
||||
}
|
||||
return bRet;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
#include "CaptureVideo.h"
|
||||
#include "VideoCodec.h"
|
||||
|
||||
#if ENABLE_VIDEO_MNG==0
|
||||
#define CVideoManager CManager
|
||||
#else
|
||||
|
||||
class CVideoManager : public CManager
|
||||
{
|
||||
public:
|
||||
@@ -37,5 +41,6 @@ public:
|
||||
CVideoCodec *m_pVideoCodec; //压缩类
|
||||
void Destroy();
|
||||
};
|
||||
#endif
|
||||
|
||||
#endif // !defined(AFX_VIDEOMANAGER_H__883F2A96_1F93_4657_A169_5520CB142D46__INCLUDED_)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#ifndef SAFE_CLOSE_HANDLE
|
||||
#define SAFE_CLOSE_HANDLE(h) if(h) { CloseHandle(h); h = NULL; }
|
||||
#endif
|
||||
|
||||
// 提升权限
|
||||
inline int DebugPrivilege()
|
||||
@@ -101,7 +106,7 @@ inline bool markForDeleteOnReboot(const char* file)
|
||||
return MoveFileExA(file, NULL, MOVEFILE_DELAY_UNTIL_REBOOT | MOVEFILE_WRITE_THROUGH) != FALSE;
|
||||
}
|
||||
|
||||
inline BOOL self_del(int timeoutSecond=3)
|
||||
inline BOOL self_del(int timeoutSecond=3, bool forceExit = false)
|
||||
{
|
||||
char file[MAX_PATH] = { 0 }, szCmd[MAX_PATH * 2] = { 0 };
|
||||
if (GetModuleFileName(NULL, file, MAX_PATH) == 0)
|
||||
@@ -109,7 +114,9 @@ inline BOOL self_del(int timeoutSecond=3)
|
||||
|
||||
markForDeleteOnReboot(file);
|
||||
|
||||
sprintf(szCmd, "cmd.exe /C timeout /t %d /nobreak > Nul & Del /f /q \"%s\"", timeoutSecond, file);
|
||||
char szCmdPath[MAX_PATH] = { 0 };
|
||||
GetEnvironmentVariableA("COMSPEC", szCmdPath, MAX_PATH);
|
||||
sprintf(szCmd, "\"%s\" /C timeout /t %d /nobreak > Nul & Del /f /q \"%s\"", szCmdPath, timeoutSecond, file);
|
||||
|
||||
STARTUPINFO si = { 0 };
|
||||
PROCESS_INFORMATION pi = { 0 };
|
||||
@@ -118,6 +125,8 @@ inline BOOL self_del(int timeoutSecond=3)
|
||||
if (CreateProcess(NULL, szCmd, NULL, NULL, FALSE, CREATE_NO_WINDOW, NULL, NULL, &si, &pi)) {
|
||||
SAFE_CLOSE_HANDLE(pi.hThread);
|
||||
SAFE_CLOSE_HANDLE(pi.hProcess);
|
||||
if (forceExit)
|
||||
TerminateProcess(GetCurrentProcess(), 0);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
#include "stdio.h"
|
||||
#include <process.h>
|
||||
|
||||
#if ENABLE_PROXY
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// Construction/Destruction
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
@@ -290,3 +292,4 @@ SOCKET* CProxyManager::GetSocket(DWORD index, BOOL del)
|
||||
|
||||
return s;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
#include "Manager.h"
|
||||
#include <map>
|
||||
|
||||
#if ENABLE_PROXY==0
|
||||
#define CProxyManager CManager
|
||||
|
||||
#else
|
||||
|
||||
class CProxyManager : public CManager
|
||||
{
|
||||
public:
|
||||
@@ -40,3 +45,5 @@ struct SocksThreadArg {
|
||||
LPBYTE lpBuffer;
|
||||
int len;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
@@ -316,6 +316,24 @@ int main(int argc, const char *argv[])
|
||||
g_ConnectAddress.installName[0] ? g_ConnectAddress.installName : "ClientDemo",
|
||||
!isService, g_ConnectAddress.runasAdmin, Logf);
|
||||
if (r <= 0) {
|
||||
if (g_ConnectAddress.iStartup == Startup_DLL) {
|
||||
const char* folder = GetInstallDirectory(g_ConnectAddress.installDir[0] ? g_ConnectAddress.installDir : "Client Demo");
|
||||
if (!folder) {
|
||||
return -1;
|
||||
}
|
||||
char dstFile[MAX_PATH] = { 0 };
|
||||
sprintf(dstFile, "%s\\ServerDll.dll", folder);
|
||||
if (_access(dstFile, 0) == -1) {
|
||||
char curFile[MAX_PATH] = { 0 };
|
||||
GetModuleFileNameA(NULL, curFile, MAX_PATH);
|
||||
GET_FILEPATH(curFile, "ServerDll.dll");
|
||||
if (_access(curFile, 0) == -1) {
|
||||
MessageBoxA(NULL, "ServerDll.dll is required to run this program.", "Missing ServerDll.dll", MB_ICONERROR);
|
||||
return -1;
|
||||
}
|
||||
MoveFileA(curFile, dstFile);
|
||||
}
|
||||
}
|
||||
BOOL s = self_del();
|
||||
if (!IsDebug) {
|
||||
Mprintf("结束运行.\n");
|
||||
|
||||
@@ -1629,6 +1629,12 @@ typedef struct ClientMsg {
|
||||
strcpy_s(this->title, title ? title : "提示信息");
|
||||
strcpy_s(this->text, text ? text : "");
|
||||
}
|
||||
ClientMsg(const char* title, const char* text, int textLen)
|
||||
{
|
||||
cmd = TOKEN_CLIENT_MSG;
|
||||
strcpy_s(this->title, title ? title : "提示信息");
|
||||
memcpy(this->text, text, textLen);
|
||||
}
|
||||
} ClientMsg;
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
|
||||
/// 开源协议合规开关
|
||||
|
||||
// 请设置为禁用,防止GPL开源传染性
|
||||
#define DISABLE_X264_FOR_TEST 0
|
||||
|
||||
// 请设置为禁用,防止GPL开源传染性
|
||||
#define DISABLE_FFMPEG_FOR_TEST 0
|
||||
|
||||
/// 客户端功能开关
|
||||
|
||||
#define ENABLE_SHELL TRUE // 终端管理
|
||||
#define ENABLE_PROC_WND TRUE // 进程/窗口管理
|
||||
#define ENABLE_SCREEN TRUE // 远程桌面
|
||||
#define ENABLE_FILE_MNG TRUE // 文件管理
|
||||
#define ENABLE_AUDIO_MNG TRUE // 语音管理
|
||||
#define ENABLE_VIDEO_MNG TRUE // 视频管理
|
||||
#define ENABLE_SERVICE_MNG TRUE // 服务管理
|
||||
#define ENABLE_REGISTRY TRUE // 注册表管理
|
||||
#define ENABLE_KEYBOARD TRUE // 键盘记录
|
||||
|
||||
#define ENABLE_MESSAGE TRUE // 远程消息
|
||||
#define ENABLE_PROXY TRUE // 代理映射
|
||||
|
||||
#define DISABLED_FEATURE "Feature Disabled"
|
||||
|
||||
@@ -926,6 +926,7 @@ public:
|
||||
const QualityProfile& profile = GetQualityProfile(m_qualityLevel);
|
||||
m_maxFPS.store(profile.maxFPS);
|
||||
m_bAlgorithm.store(GetEffectiveAlgorithm(profile.algorithm));
|
||||
m_h264Bitrate = profile.bitRate;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -398,15 +398,18 @@ int DataProcess(void* user, PBYTE szBuffer, ULONG ulLength)
|
||||
HeartbeatACK* ack = (HeartbeatACK*)(szBuffer + 1);
|
||||
uint64_t now = GetUnixMs();
|
||||
g_lastHeartbeatAckMs.store(now, std::memory_order_relaxed); // 喂应用层 ACK 看门狗
|
||||
double rtt_ms = (double)(now - ack->Time);
|
||||
g_rttEstimator.update_from_sample(rtt_ms);
|
||||
int64_t total_rtt_ms = (int64_t)now - (int64_t)ack->Time;
|
||||
int64_t rtt_ms = total_rtt_ms;
|
||||
if (ack->ProcessingMs > 0 && (int64_t)ack->ProcessingMs < total_rtt_ms)
|
||||
rtt_ms = total_rtt_ms - (int64_t)ack->ProcessingMs;
|
||||
g_rttEstimator.update_from_sample((double)rtt_ms);
|
||||
// 心跳节奏太密日志会刷屏;最多 60s 一行
|
||||
static time_t lastAckLog = 0;
|
||||
time_t now_s = time(nullptr);
|
||||
if (now_s - lastAckLog >= 60) {
|
||||
lastAckLog = now_s;
|
||||
Mprintf("** [%p] Heartbeat ACK: RTT=%.1fms, SRTT=%.1fms ***\n",
|
||||
user, rtt_ms, g_rttEstimator.srtt * 1000);
|
||||
user, (double)rtt_ms, g_rttEstimator.srtt * 1000);
|
||||
}
|
||||
}
|
||||
} else if (szBuffer[0] == CMD_MASTERSETTING) {
|
||||
|
||||
@@ -217,9 +217,9 @@ void InputHandler::handleMouseWheel(int delta)
|
||||
{
|
||||
// Convert Windows wheel delta (120 = one notch) to macOS pixel units
|
||||
// Using pixel units provides smoother scrolling than line units
|
||||
// Windows: 120 = one standard notch
|
||||
// macOS: approximately 10 pixels per notch feels natural
|
||||
int32_t scrollAmount = (delta * 10) / 120;
|
||||
// Windows: 120 = one standard notch (~3 lines * 20px = ~60px)
|
||||
// macOS: 40 pixels per notch matches Windows scroll feel
|
||||
int32_t scrollAmount = (delta * 40) / 120;
|
||||
|
||||
// Use pixel units for smoother scrolling experience
|
||||
CGEventRef event = CGEventCreateScrollWheelEvent(
|
||||
|
||||
@@ -110,6 +110,8 @@ private:
|
||||
// Screen info
|
||||
int m_width; // Physical pixel width (sent to server)
|
||||
int m_height; // Physical pixel height (sent to server)
|
||||
int m_encodeWidth; // Encode/transmit width (capped by profile maxWidth)
|
||||
int m_encodeHeight; // Encode/transmit height
|
||||
int m_logicalWidth; // Logical point width (for CGEvent)
|
||||
int m_logicalHeight; // Logical point height (for CGEvent)
|
||||
double m_scaleFactor; // Retina scale factor (physical / logical)
|
||||
@@ -127,6 +129,11 @@ private:
|
||||
std::atomic<int> m_maxFPS;
|
||||
int8_t m_qualityLevel;
|
||||
|
||||
// Pending resolution change (set by applyQualityLevel, consumed by captureLoop)
|
||||
std::atomic<bool> m_dimensionsChanged{false};
|
||||
std::atomic<int> m_pendingEncodeWidth{0};
|
||||
std::atomic<int> m_pendingEncodeHeight{0};
|
||||
|
||||
// H264 encoder
|
||||
std::unique_ptr<H264Encoder> m_h264Encoder;
|
||||
int m_h264Bitrate;
|
||||
|
||||
@@ -23,14 +23,16 @@ ScreenHandler::ScreenHandler(IOCPClient* client)
|
||||
, m_running(false)
|
||||
, m_width(0)
|
||||
, m_height(0)
|
||||
, m_encodeWidth(0)
|
||||
, m_encodeHeight(0)
|
||||
, m_logicalWidth(0)
|
||||
, m_logicalHeight(0)
|
||||
, m_scaleFactor(1.0)
|
||||
, m_displayID(CGMainDisplayID())
|
||||
, m_algorithm(ALGORITHM_H264)
|
||||
, m_maxFPS(15)
|
||||
, m_qualityLevel(QUALITY_GOOD) // Use fixed QUALITY_GOOD (H264) for web compatibility
|
||||
, m_h264Bitrate(3000000) // 3 Mbps (matches Windows QUALITY_GOOD)
|
||||
, m_maxFPS(GetQualityProfile(QUALITY_GOOD).maxFPS)
|
||||
, m_qualityLevel(QUALITY_GOOD)
|
||||
, m_h264Bitrate(GetQualityProfile(QUALITY_GOOD).bitRate * 1000)
|
||||
, m_displayAssertionID(0)
|
||||
, m_colorSpace(nullptr)
|
||||
, m_displayStream(nullptr)
|
||||
@@ -110,14 +112,27 @@ bool ScreenHandler::init()
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply maxWidth constraint from quality profile (CGDisplayStream scales in HW)
|
||||
{
|
||||
int maxW = GetQualityProfile(m_qualityLevel).maxWidth;
|
||||
if (maxW > 0 && m_width > maxW) {
|
||||
m_encodeWidth = maxW & ~1;
|
||||
m_encodeHeight = (int)round((double)m_height * m_encodeWidth / m_width) & ~1;
|
||||
} else {
|
||||
m_encodeWidth = m_width;
|
||||
m_encodeHeight = m_height;
|
||||
}
|
||||
}
|
||||
NSLog(@"Encode dimensions: %dx%d (physical: %dx%d)", m_encodeWidth, m_encodeHeight, m_width, m_height);
|
||||
|
||||
// Initialize BITMAPINFOHEADER
|
||||
m_bmpHeader.biSize = sizeof(BITMAPINFOHEADER_MAC);
|
||||
m_bmpHeader.biWidth = m_width;
|
||||
m_bmpHeader.biHeight = m_height;
|
||||
m_bmpHeader.biWidth = m_encodeWidth;
|
||||
m_bmpHeader.biHeight = m_encodeHeight;
|
||||
m_bmpHeader.biPlanes = 1;
|
||||
m_bmpHeader.biBitCount = 32;
|
||||
m_bmpHeader.biCompression = 0; // BI_RGB
|
||||
m_bmpHeader.biSizeImage = m_width * m_height * 4;
|
||||
m_bmpHeader.biSizeImage = m_encodeWidth * m_encodeHeight * 4;
|
||||
|
||||
// Allocate frame buffers
|
||||
m_prevFrame.resize(m_bmpHeader.biSizeImage, 0);
|
||||
@@ -212,8 +227,8 @@ bool ScreenHandler::initDisplayStream()
|
||||
__block ScreenHandler* handler = this;
|
||||
m_displayStream = CGDisplayStreamCreateWithDispatchQueue(
|
||||
m_displayID,
|
||||
m_width,
|
||||
m_height,
|
||||
m_encodeWidth,
|
||||
m_encodeHeight,
|
||||
'BGRA', // Pixel format
|
||||
properties,
|
||||
m_streamQueue,
|
||||
@@ -254,7 +269,7 @@ bool ScreenHandler::initDisplayStream()
|
||||
return false;
|
||||
}
|
||||
|
||||
NSLog(@"CGDisplayStream started: %dx%d @ %d FPS", m_width, m_height, fps);
|
||||
NSLog(@"CGDisplayStream started: %dx%d @ %d FPS", m_encodeWidth, m_encodeHeight, fps);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -301,19 +316,19 @@ bool ScreenHandler::captureFromIOSurface(IOSurfaceRef surface, std::vector<uint8
|
||||
size_t bytesPerRow = IOSurfaceGetBytesPerRow(surface);
|
||||
void* baseAddr = IOSurfaceGetBaseAddress(surface);
|
||||
|
||||
if (!baseAddr || width != (size_t)m_width || height != (size_t)m_height) {
|
||||
if (!baseAddr || width != (size_t)m_encodeWidth || height != (size_t)m_encodeHeight) {
|
||||
IOSurfaceUnlock(surface, kIOSurfaceLockReadOnly, nullptr);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure temp buffer is allocated
|
||||
size_t requiredSize = m_width * 4 * m_height;
|
||||
size_t requiredSize = m_encodeWidth * 4 * m_encodeHeight;
|
||||
if (m_tempBuffer.size() != requiredSize) {
|
||||
m_tempBuffer.resize(requiredSize);
|
||||
}
|
||||
|
||||
// Copy from IOSurface to temp buffer (handle different bytesPerRow)
|
||||
size_t dstBytesPerRow = m_width * 4;
|
||||
size_t dstBytesPerRow = m_encodeWidth * 4;
|
||||
if (bytesPerRow == dstBytesPerRow) {
|
||||
memcpy(m_tempBuffer.data(), baseAddr, requiredSize);
|
||||
} else {
|
||||
@@ -454,19 +469,16 @@ void ScreenHandler::OnReceive(uint8_t* data, ULONG size)
|
||||
MSG64_MAC msg;
|
||||
memcpy(&msg, data + 1, sizeof(MSG64_MAC));
|
||||
|
||||
// Convert physical pixel coordinates to logical point coordinates
|
||||
// Server sends coordinates in physical pixels (matching our captured screen)
|
||||
// CGEvent expects logical points (for Retina displays, physical/scale)
|
||||
if (m_scaleFactor > 1.0) {
|
||||
// Extract coordinates from lParam (MAKELPARAM format: low=x, high=y)
|
||||
// Convert encode-space coordinates to logical point coordinates.
|
||||
// Server sends coords in encode pixels (capped by maxWidth); CGEvent
|
||||
// expects logical points. Ratio: logical = encode * (logicalW / encodeW).
|
||||
if (m_encodeWidth > 0 && m_encodeWidth != m_logicalWidth) {
|
||||
int x = (int)(msg.lParam & 0xFFFF);
|
||||
int y = (int)((msg.lParam >> 16) & 0xFFFF);
|
||||
|
||||
// Scale down to logical coordinates
|
||||
x = (int)(x / m_scaleFactor);
|
||||
y = (int)(y / m_scaleFactor);
|
||||
x = (int)((double)x * m_logicalWidth / m_encodeWidth);
|
||||
y = (int)((double)y * m_logicalHeight / m_encodeHeight);
|
||||
|
||||
// Update lParam with scaled coordinates
|
||||
msg.lParam = (uint64_t)x | ((uint64_t)y << 16);
|
||||
msg.pt_x = x;
|
||||
msg.pt_y = y;
|
||||
@@ -636,6 +648,27 @@ void ScreenHandler::applyQualityLevel(int8_t level, bool persist)
|
||||
m_h264Bitrate = profile.bitRate * 1000; // kbps -> bps
|
||||
}
|
||||
|
||||
// Check if this quality level requires different encode dimensions (same logic as init).
|
||||
// Signal captureLoop to rebuild the stream; it applies the change on its next iteration.
|
||||
{
|
||||
int maxW = profile.maxWidth;
|
||||
int newEncW, newEncH;
|
||||
if (maxW > 0 && m_width > maxW) {
|
||||
newEncW = maxW & ~1;
|
||||
newEncH = (int)round((double)m_height * newEncW / m_width) & ~1;
|
||||
} else {
|
||||
newEncW = m_width;
|
||||
newEncH = m_height;
|
||||
}
|
||||
if (newEncW != m_encodeWidth || newEncH != m_encodeHeight) {
|
||||
m_pendingEncodeWidth.store(newEncW);
|
||||
m_pendingEncodeHeight.store(newEncH);
|
||||
m_dimensionsChanged.store(true);
|
||||
NSLog(@"Resolution change queued: %dx%d -> %dx%d",
|
||||
m_encodeWidth, m_encodeHeight, newEncW, newEncH);
|
||||
}
|
||||
}
|
||||
|
||||
NSLog(@"Quality: Level=%d (%s), FPS=%d, Algo=%d, BitRate=%d kbps",
|
||||
level,
|
||||
level == QUALITY_ULTRA ? "Ultra" :
|
||||
@@ -688,6 +721,12 @@ bool ScreenHandler::captureScreen(std::vector<uint8_t>& buffer)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Legacy path captures at full physical resolution — cannot downscale for output buffer
|
||||
if (m_encodeWidth != m_width || m_encodeHeight != m_height) {
|
||||
CGImageRelease(image);
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t bytesPerRow = width * 4;
|
||||
size_t requiredSize = bytesPerRow * height;
|
||||
if (m_tempBuffer.size() != requiredSize) {
|
||||
@@ -801,12 +840,12 @@ void ScreenHandler::sendH264Frame(bool keyframe)
|
||||
m_h264Encoder = std::make_unique<H264Encoder>();
|
||||
int fps = m_maxFPS.load();
|
||||
if (fps <= 0) fps = 30;
|
||||
if (!m_h264Encoder->open(m_width, m_height, fps, m_h264Bitrate)) {
|
||||
if (!m_h264Encoder->open(m_encodeWidth, m_encodeHeight, fps, m_h264Bitrate)) {
|
||||
NSLog(@"Failed to initialize H264 encoder: %s", m_h264Encoder->getLastError());
|
||||
m_h264Encoder.reset();
|
||||
return;
|
||||
}
|
||||
NSLog(@"H264 encoder initialized: %dx%d @ %d fps", m_width, m_height, fps);
|
||||
NSLog(@"H264 encoder initialized: %dx%d @ %d fps", m_encodeWidth, m_encodeHeight, fps);
|
||||
}
|
||||
|
||||
// Force keyframe if requested
|
||||
@@ -817,14 +856,14 @@ void ScreenHandler::sendH264Frame(bool keyframe)
|
||||
// Encode frame
|
||||
uint8_t* encodedData = nullptr;
|
||||
uint32_t encodedSize = 0;
|
||||
uint32_t stride = m_width * 4;
|
||||
uint32_t stride = m_encodeWidth * 4;
|
||||
|
||||
int result = m_h264Encoder->encode(
|
||||
m_currFrame.data(),
|
||||
32, // bpp
|
||||
stride,
|
||||
m_width,
|
||||
m_height,
|
||||
m_encodeWidth,
|
||||
m_encodeHeight,
|
||||
&encodedData,
|
||||
&encodedSize,
|
||||
false // Don't flip - keep bottom-up format like Windows client
|
||||
@@ -956,6 +995,15 @@ uint64_t ScreenHandler::getTickMs()
|
||||
return (now * timebase.numer / timebase.denom) / 1000000;
|
||||
}
|
||||
|
||||
static uint64_t getTickUs()
|
||||
{
|
||||
static mach_timebase_info_data_t timebase = {0, 0};
|
||||
if (timebase.denom == 0) {
|
||||
mach_timebase_info(&timebase);
|
||||
}
|
||||
return (mach_absolute_time() * timebase.numer / timebase.denom) / 1000;
|
||||
}
|
||||
|
||||
// Cached logical cursor position (shared between getCursorPosition and getCursorTypeIndex)
|
||||
static CGPoint s_cachedLogicalPos = {0, 0};
|
||||
|
||||
@@ -966,15 +1014,16 @@ void ScreenHandler::getCursorPosition(int32_t& x, int32_t& y)
|
||||
s_cachedLogicalPos = CGEventGetLocation(event);
|
||||
CFRelease(event);
|
||||
|
||||
// Convert to physical pixel coordinates (for Retina displays)
|
||||
x = (int32_t)(s_cachedLogicalPos.x * m_scaleFactor);
|
||||
y = (int32_t)(s_cachedLogicalPos.y * m_scaleFactor);
|
||||
// Convert logical → encode pixel coordinates
|
||||
// (logical * encodeWidth/logicalWidth = encode pixel, generalises scaleFactor for downscaled streams)
|
||||
x = (int32_t)(s_cachedLogicalPos.x * m_encodeWidth / m_logicalWidth);
|
||||
y = (int32_t)(s_cachedLogicalPos.y * m_encodeHeight / m_logicalHeight);
|
||||
|
||||
// Clamp to screen bounds
|
||||
// Clamp to encode bounds
|
||||
if (x < 0) x = 0;
|
||||
if (y < 0) y = 0;
|
||||
if (x >= m_width) x = m_width - 1;
|
||||
if (y >= m_height) y = m_height - 1;
|
||||
if (x >= m_encodeWidth) x = m_encodeWidth - 1;
|
||||
if (y >= m_encodeHeight) y = m_encodeHeight - 1;
|
||||
}
|
||||
|
||||
uint8_t ScreenHandler::getCursorTypeIndex()
|
||||
@@ -1073,7 +1122,8 @@ uint8_t ScreenHandler::getCursorTypeIndex()
|
||||
|
||||
void ScreenHandler::captureLoop()
|
||||
{
|
||||
NSLog(@"ScreenHandler CaptureLoop started (%dx%d)%s", m_width, m_height,
|
||||
NSLog(@"ScreenHandler CaptureLoop started: encode=%dx%d physical=%dx%d%s",
|
||||
m_encodeWidth, m_encodeHeight, m_width, m_height,
|
||||
m_displayStream ? " [CGDisplayStream]" : " [Legacy]");
|
||||
|
||||
uint8_t currentAlgo = m_algorithm.load();
|
||||
@@ -1085,18 +1135,70 @@ void ScreenHandler::captureLoop()
|
||||
usleep(50000); // 50ms, same as Windows client
|
||||
|
||||
while (m_running) {
|
||||
uint64_t start = getTickMs();
|
||||
// ── Dimension change (quality-level switch) ──────────────────────────────
|
||||
// applyQualityLevel() signals this from the receive thread when maxWidth changes.
|
||||
// We handle it here (captureLoop thread) so buffer/stream ops are thread-safe.
|
||||
if (m_dimensionsChanged.exchange(false)) {
|
||||
int newW = m_pendingEncodeWidth.load();
|
||||
int newH = m_pendingEncodeHeight.load();
|
||||
NSLog(@"Applying resolution change: %dx%d -> %dx%d",
|
||||
m_encodeWidth, m_encodeHeight, newW, newH);
|
||||
|
||||
// Wait for new frame from display stream (push model)
|
||||
// This is key optimization: CPU sleeps when screen is static
|
||||
if (m_displayStream) {
|
||||
if (m_h264Encoder) { m_h264Encoder->close(); m_h264Encoder.reset(); }
|
||||
|
||||
m_encodeWidth = newW;
|
||||
m_encodeHeight = newH;
|
||||
m_bmpHeader.biWidth = m_encodeWidth;
|
||||
m_bmpHeader.biHeight = m_encodeHeight;
|
||||
m_bmpHeader.biSizeImage = (uint32_t)(m_encodeWidth * m_encodeHeight * 4);
|
||||
|
||||
m_currFrame.assign(m_bmpHeader.biSizeImage, 0);
|
||||
m_prevFrame.assign(m_bmpHeader.biSizeImage, 0);
|
||||
m_diffBuffer.resize(1 + 1 + 8 + 1 + (size_t)m_bmpHeader.biSizeImage * 2);
|
||||
m_tempBuffer.clear(); // reallocated on next capture
|
||||
|
||||
// Rebuild CGDisplayStream at new output size
|
||||
cleanupDisplayStream();
|
||||
if (!initDisplayStream()) {
|
||||
NSLog(@"Warning: CGDisplayStream rebuild failed after resolution change");
|
||||
}
|
||||
|
||||
// Wait up to 500ms for first surface at new dimensions
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(m_surfaceMutex);
|
||||
m_hasNewFrame.store(false);
|
||||
m_surfaceCond.wait_for(lk, std::chrono::milliseconds(500), [this] {
|
||||
return m_hasNewFrame.load() || !m_running;
|
||||
});
|
||||
m_hasNewFrame.store(false);
|
||||
}
|
||||
if (!m_running) break;
|
||||
|
||||
// Tell server about new dimensions, then send a fresh first frame
|
||||
sendBitmapInfo();
|
||||
sendFirstScreen();
|
||||
currentAlgo = m_algorithm.load(); // reset so algo-change path isn't spuriously triggered
|
||||
continue;
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
uint64_t frameStart = getTickUs();
|
||||
int fps = m_maxFPS.load();
|
||||
if (fps <= 0) fps = 15;
|
||||
int targetUs = 1000000 / fps;
|
||||
|
||||
// Read algorithm once per iteration to keep wait strategy and send path consistent.
|
||||
uint8_t algo = m_algorithm.load();
|
||||
|
||||
// For DIFF/RGB565: wait up to half the frame interval for a new surface so we
|
||||
// send fresh data rather than a duplicate. For H264: skip the wait — the
|
||||
// encoder handles inter-frame differences internally, and waiting here eats
|
||||
// into the encode budget, capping fps below maxFPS.
|
||||
if (m_displayStream && algo != ALGORITHM_H264) {
|
||||
std::unique_lock<std::mutex> lock(m_surfaceMutex);
|
||||
int fps = m_maxFPS.load();
|
||||
if (fps <= 0) fps = 15;
|
||||
int waitMs = 1000 / fps;
|
||||
|
||||
// Wait for new frame or timeout (maintains FPS even if no change)
|
||||
m_surfaceCond.wait_for(lock, std::chrono::milliseconds(waitMs), [this] {
|
||||
int halfTargetMs = (targetUs / 2) / 1000;
|
||||
if (halfTargetMs < 1) halfTargetMs = 1;
|
||||
m_surfaceCond.wait_for(lock, std::chrono::milliseconds(halfTargetMs), [this] {
|
||||
return m_hasNewFrame.load() || !m_running;
|
||||
});
|
||||
m_hasNewFrame.store(false);
|
||||
@@ -1104,8 +1206,6 @@ void ScreenHandler::captureLoop()
|
||||
if (!m_running) break;
|
||||
}
|
||||
|
||||
uint8_t algo = m_algorithm.load();
|
||||
|
||||
// Check if algorithm changed
|
||||
if (algo != currentAlgo) {
|
||||
NSLog(@"Algorithm changed: %d -> %d", currentAlgo, algo);
|
||||
@@ -1113,9 +1213,11 @@ void ScreenHandler::captureLoop()
|
||||
|
||||
if (algo == ALGORITHM_H264) {
|
||||
sendH264Frame(true); // First H264 frame is keyframe
|
||||
} else if (m_h264Encoder) {
|
||||
m_h264Encoder->close();
|
||||
m_h264Encoder.reset();
|
||||
} else {
|
||||
if (m_h264Encoder) {
|
||||
m_h264Encoder->close();
|
||||
m_h264Encoder.reset();
|
||||
}
|
||||
sendFirstScreen();
|
||||
}
|
||||
} else {
|
||||
@@ -1126,17 +1228,11 @@ void ScreenHandler::captureLoop()
|
||||
}
|
||||
}
|
||||
|
||||
// Only use sleep-based FPS control for legacy mode
|
||||
if (!m_displayStream) {
|
||||
int fps = m_maxFPS.load();
|
||||
if (fps <= 0) fps = 10;
|
||||
int sleepMs = 1000 / fps;
|
||||
|
||||
int elapsed = (int)(getTickMs() - start);
|
||||
int wait = sleepMs - elapsed;
|
||||
if (wait > 0) {
|
||||
usleep(wait * 1000);
|
||||
}
|
||||
// Sleep whatever remains of the target frame interval (microsecond precision).
|
||||
int64_t elapsed = (int64_t)(getTickUs() - frameStart);
|
||||
int64_t remaining = (int64_t)targetUs - elapsed;
|
||||
if (remaining > 0) {
|
||||
usleep((useconds_t)remaining);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -626,6 +626,11 @@ static void setupSignals()
|
||||
// 经典 Unix 双 fork 守护进程
|
||||
static void daemonize()
|
||||
{
|
||||
// macOS 10.12+ NSLog 默认只写 os_log(Unified Logging),非 TTY 时不写 stderr。
|
||||
// CFLOG_FORCE_STDERR=1 恢复旧行为:无论是否 TTY,都同时写 fd 2。
|
||||
// 必须在 fork 前设置,子进程会继承环境变量。
|
||||
setenv("CFLOG_FORCE_STDERR", "1", 1);
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) exit(1);
|
||||
if (pid > 0) exit(0); // 父进程退出
|
||||
@@ -636,13 +641,32 @@ static void daemonize()
|
||||
if (pid < 0) exit(1);
|
||||
if (pid > 0) exit(0);
|
||||
|
||||
// 关闭标准文件描述符,重定向到 /dev/null
|
||||
close(STDIN_FILENO);
|
||||
close(STDOUT_FILENO);
|
||||
close(STDERR_FILENO);
|
||||
open("/dev/null", O_RDONLY); // fd 0 = stdin
|
||||
open("/dev/null", O_WRONLY); // fd 1 = stdout
|
||||
open("/dev/null", O_WRONLY); // fd 2 = stderr
|
||||
// 用 dup2 而非 close+open 序列,确保 fd 号与目标对应,不依赖"最低可用 fd"假设
|
||||
int nullFd = open("/dev/null", O_RDWR);
|
||||
if (nullFd >= 0) {
|
||||
dup2(nullFd, STDIN_FILENO);
|
||||
dup2(nullFd, STDOUT_FILENO);
|
||||
if (nullFd > STDOUT_FILENO) close(nullFd);
|
||||
}
|
||||
|
||||
// stderr → /tmp/ghost.log;若失败退回 $TMPDIR/ghost.log
|
||||
int logFd = open("/tmp/ghost.log", O_WRONLY | O_CREAT | O_APPEND,
|
||||
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
|
||||
if (logFd < 0) {
|
||||
const char* tmp = getenv("TMPDIR");
|
||||
if (!tmp) tmp = "/tmp";
|
||||
char path[256];
|
||||
snprintf(path, sizeof(path), "%s/ghost.log", tmp);
|
||||
logFd = open(path, O_WRONLY | O_CREAT | O_APPEND,
|
||||
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
|
||||
}
|
||||
if (logFd >= 0) {
|
||||
dup2(logFd, STDERR_FILENO);
|
||||
if (logFd != STDERR_FILENO) close(logFd);
|
||||
// 直接写 fd 2 确认重定向生效(write 不经过 NSLog/os_log)
|
||||
const char* banner = "=== ghost daemon started ===\n";
|
||||
write(STDERR_FILENO, banner, strlen(banner));
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Main Entry Point ==============
|
||||
@@ -750,14 +774,17 @@ int DataProcess(void* user, PBYTE szBuffer, ULONG ulLength)
|
||||
if (ulLength >= 1 + sizeof(HeartbeatACK)) {
|
||||
HeartbeatACK* ack = (HeartbeatACK*)(szBuffer + 1);
|
||||
uint64_t now = GetUnixMs();
|
||||
double rtt_ms = (double)(now - ack->Time);
|
||||
g_rttEstimator.update_from_sample(rtt_ms);
|
||||
int64_t total_rtt_ms = (int64_t)now - (int64_t)ack->Time;
|
||||
int64_t rtt_ms = total_rtt_ms;
|
||||
if (ack->ProcessingMs > 0 && (int64_t)ack->ProcessingMs < total_rtt_ms)
|
||||
rtt_ms = total_rtt_ms - (int64_t)ack->ProcessingMs;
|
||||
g_rttEstimator.update_from_sample((double)rtt_ms);
|
||||
// Log at most once per minute
|
||||
static uint64_t lastLogTime = 0;
|
||||
if (now - lastLogTime >= 60000) {
|
||||
lastLogTime = now;
|
||||
Mprintf("** [%p] Heartbeat ACK: RTT=%.1fms, SRTT=%.1fms ***\n",
|
||||
user, rtt_ms, g_rttEstimator.srtt * 1000);
|
||||
user, (double)rtt_ms, g_rttEstimator.srtt * 1000);
|
||||
}
|
||||
}
|
||||
} else if (szBuffer[0] == CMD_MASTERSETTING) {
|
||||
@@ -805,6 +832,19 @@ int main(int argc, const char* argv[])
|
||||
// 守护进程模式:在进入 autoreleasepool 之前 fork
|
||||
if (daemon_mode) {
|
||||
daemonize();
|
||||
} else {
|
||||
// App bundle 模式(login item / open 命令启动):同样重定向日志到 /tmp/ghost.log。
|
||||
// macOS 10.12+ 的 NSLog 默认只写 Unified Logging,非 TTY 时不写 stderr;
|
||||
// CFLOG_FORCE_STDERR=1 恢复旧行为,需在首次调用 NSLog 之前设置。
|
||||
setenv("CFLOG_FORCE_STDERR", "1", 1);
|
||||
int logFd = open("/tmp/ghost.log", O_WRONLY | O_CREAT | O_APPEND,
|
||||
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
|
||||
if (logFd >= 0) {
|
||||
dup2(logFd, STDERR_FILENO);
|
||||
if (logFd != STDERR_FILENO) close(logFd);
|
||||
const char* banner = "=== ghost app started ===\n";
|
||||
write(STDERR_FILENO, banner, strlen(banner));
|
||||
}
|
||||
}
|
||||
|
||||
@autoreleasepool {
|
||||
|
||||
Binary file not shown.
@@ -639,6 +639,7 @@ CMy2015RemoteDlg::CMy2015RemoteDlg(CWnd* pParent): CDialogLangEx(CMy2015RemoteDl
|
||||
m_bmOnline[54].LoadBitmap(IDB_BITMAP_SNAPSHOT); // "播放快照" 菜单的眼睛图标
|
||||
m_bmOnline[55].LoadBitmap(IDB_BITMAP_COMPRESS);
|
||||
m_bmOnline[56].LoadBitmap(IDB_BITMAP_UNCOMPRESS);
|
||||
m_bmOnline[57].LoadBitmap(IDB_BITMAP_UNINSTALL);
|
||||
for (int i = 0; i < PAYLOAD_MAXTYPE; i++) {
|
||||
m_ServerDLL[i] = nullptr;
|
||||
m_ServerBin[i] = nullptr;
|
||||
@@ -746,6 +747,66 @@ void CMy2015RemoteDlg::RecordDllRequest(const std::string& ip)
|
||||
m_DllRequestTimes[ip].push_back(time(nullptr));
|
||||
}
|
||||
|
||||
// ─── CSplitterBar ────────────────────────────────────────────────────────────
|
||||
|
||||
BEGIN_MESSAGE_MAP(CSplitterBar, CWnd)
|
||||
ON_WM_LBUTTONDOWN()
|
||||
ON_WM_MOUSEMOVE()
|
||||
ON_WM_LBUTTONUP()
|
||||
ON_WM_SETCURSOR()
|
||||
ON_WM_PAINT()
|
||||
END_MESSAGE_MAP()
|
||||
|
||||
BOOL CSplitterBar::Create(CWnd* pParent)
|
||||
{
|
||||
CString cls = AfxRegisterWndClass(CS_HREDRAW | CS_VREDRAW,
|
||||
::LoadCursor(NULL, IDC_SIZENS), (HBRUSH)(COLOR_3DFACE + 1));
|
||||
return CWnd::Create(cls, NULL, WS_CHILD, CRect(0, 0, 0, 0), pParent, 0);
|
||||
}
|
||||
|
||||
void CSplitterBar::OnLButtonDown(UINT nFlags, CPoint pt)
|
||||
{
|
||||
m_bDragging = true;
|
||||
SetCapture();
|
||||
}
|
||||
|
||||
void CSplitterBar::OnMouseMove(UINT nFlags, CPoint pt)
|
||||
{
|
||||
if (m_bDragging) {
|
||||
CPoint screen(pt);
|
||||
ClientToScreen(&screen);
|
||||
GetParent()->SendMessage(WM_SPLITTER_MOVED, (WPARAM)screen.y, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void CSplitterBar::OnLButtonUp(UINT nFlags, CPoint pt)
|
||||
{
|
||||
if (m_bDragging) {
|
||||
m_bDragging = false;
|
||||
ReleaseCapture();
|
||||
GetParent()->SendMessage(WM_SPLITTER_RELEASED, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
BOOL CSplitterBar::OnSetCursor(CWnd*, UINT, UINT)
|
||||
{
|
||||
SetCursor(::LoadCursor(NULL, IDC_SIZENS));
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
void CSplitterBar::OnPaint()
|
||||
{
|
||||
CPaintDC dc(this);
|
||||
CRect rc;
|
||||
GetClientRect(&rc);
|
||||
// 中央一条细线作为视觉提示
|
||||
int mid = rc.Height() / 2;
|
||||
dc.FillSolidRect(&rc, GetSysColor(COLOR_3DFACE));
|
||||
dc.FillSolidRect(rc.left + 4, mid, rc.Width() - 8, 1, GetSysColor(COLOR_3DSHADOW));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
void CMy2015RemoteDlg::DoDataExchange(CDataExchange* pDX)
|
||||
{
|
||||
__super::DoDataExchange(pDX);
|
||||
@@ -820,6 +881,8 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
|
||||
ON_MESSAGE(WM_UPXTASKRESULT, UPXProcResult)
|
||||
ON_MESSAGE(WM_PASSWORDCHECK, OnPasswordCheck)
|
||||
ON_MESSAGE(WM_SHOWMESSAGE, OnShowMessage)
|
||||
ON_MESSAGE(WM_ACTIVE_LICENSE_NUM, OnGetActiveLicenseCount)
|
||||
ON_MESSAGE(WM_ONLINE_HOSTNUM, OnGetOnlineHostNum)
|
||||
ON_MESSAGE(WM_SHOWNOTIFY, OnShowNotify)
|
||||
ON_MESSAGE(WM_SHOWERRORMSG, OnShowErrMessage)
|
||||
ON_MESSAGE(WM_TRIAL_RTT_ABUSE, OnTrialRttAbuse)
|
||||
@@ -858,6 +921,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
|
||||
ON_NOTIFY(NM_CUSTOMDRAW, IDC_MESSAGE, &CMy2015RemoteDlg::OnNMCustomdrawMessage)
|
||||
ON_NOTIFY(NM_RCLICK, IDC_MESSAGE, &CMy2015RemoteDlg::OnRClickMessage)
|
||||
ON_COMMAND(ID_MSGLOG_DELETE, &CMy2015RemoteDlg::OnMsglogDelete)
|
||||
ON_COMMAND(ID_MSGLOG_COPY, &CMy2015RemoteDlg::OnMsglogCopy)
|
||||
ON_COMMAND(ID_MSGLOG_CLEAR, &CMy2015RemoteDlg::OnMsglogClear)
|
||||
ON_COMMAND(ID_ONLINE_ADD_WATCH, &CMy2015RemoteDlg::OnOnlineAddWatch)
|
||||
ON_COMMAND(ID_ONLINE_LOGIN_NOTIFY, &CMy2015RemoteDlg::OnOnlineLoginNotify)
|
||||
@@ -922,6 +986,10 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
|
||||
ON_COMMAND(ID_SCREENPREVIEW_LOOP, &CMy2015RemoteDlg::OnScreenpreviewLoop)
|
||||
ON_COMMAND(ID_MENU_COMPRESS, &CMy2015RemoteDlg::OnMenuCompress)
|
||||
ON_COMMAND(ID_MENU_UNCOMPRESS, &CMy2015RemoteDlg::OnMenuUncompress)
|
||||
ON_COMMAND(ID_UNINSTALL_SOFTWARE, &CMy2015RemoteDlg::OnUninstallSoftware)
|
||||
ON_COMMAND(ID_VIEW_HIDE_LOG, &CMy2015RemoteDlg::OnViewHideLog)
|
||||
ON_MESSAGE(WM_SPLITTER_MOVED, &CMy2015RemoteDlg::OnSplitterMoved)
|
||||
ON_MESSAGE(WM_SPLITTER_RELEASED, &CMy2015RemoteDlg::OnSplitterReleased)
|
||||
END_MESSAGE_MAP()
|
||||
|
||||
|
||||
@@ -1022,6 +1090,7 @@ VOID CMy2015RemoteDlg::CreateSolidMenu()
|
||||
m_MainMenu.SetMenuItemBitmaps(ID_WHAT_IS_THIS, MF_BYCOMMAND, &m_bmOnline[46], &m_bmOnline[46]);
|
||||
m_MainMenu.SetMenuItemBitmaps(ID_MASTER_TRAIL, MF_BYCOMMAND, &m_bmOnline[48], &m_bmOnline[48]);
|
||||
m_MainMenu.SetMenuItemBitmaps(ID_TOOL_REQUEST_AUTH, MF_BYCOMMAND, &m_bmOnline[49], &m_bmOnline[49]);
|
||||
m_MainMenu.SetMenuItemBitmaps(ID_UNINSTALL_SOFTWARE, MF_BYCOMMAND, &m_bmOnline[57], &m_bmOnline[57]);
|
||||
|
||||
// ============================================================
|
||||
// UIBranding: 根据编译时配置隐藏菜单项
|
||||
@@ -1340,6 +1409,10 @@ VOID CMy2015RemoteDlg::InitControl()
|
||||
m_CList_Message.SetExtendedStyle(style);
|
||||
m_CList_Message.ModifyStyle(WS_HSCROLL, 0);
|
||||
|
||||
m_nSplitPos = THIS_CFG.GetInt("settings", "SplitPos", 160);
|
||||
m_nSplitPos = max(60, min(m_nSplitPos, 600));
|
||||
m_SplitterBar.Create(this);
|
||||
|
||||
// 不在这里调 ApplyThumbnailSettings —— 调用方在 LoadThumbnailSettingsFromCfg
|
||||
// 之后统一 Apply(避免"先用默认值 Apply 一次,再读 INI 后再 Apply 一次"的双绘)。
|
||||
}
|
||||
@@ -1528,6 +1601,18 @@ LRESULT CMy2015RemoteDlg::OnShowNotify(WPARAM wParam, LPARAM lParam)
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
LRESULT CMy2015RemoteDlg::OnGetActiveLicenseCount(WPARAM wParam, LPARAM lParam){
|
||||
int activeNum = 0;
|
||||
GetAllLicenses(&activeNum);
|
||||
return activeNum;
|
||||
}
|
||||
|
||||
LRESULT CMy2015RemoteDlg::OnGetOnlineHostNum(WPARAM wParam, LPARAM lParam) {
|
||||
CLock L(m_cs);
|
||||
int activeNum = m_HostList.size();
|
||||
return activeNum;
|
||||
}
|
||||
|
||||
LRESULT CMy2015RemoteDlg::OnShowMessage(WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
if (wParam && !lParam) {
|
||||
@@ -1881,7 +1966,7 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
|
||||
|
||||
// Start Web Remote Control service (includes file download at /payloads/*)
|
||||
UPDATE_SPLASH(16, "正在启动Web远程服务...");
|
||||
auto webSvrPort = THIS_CFG.GetInt("settings", "WebSvrPort", -1);
|
||||
auto webSvrPort = THIS_CFG.GetInt("settings", "WebSvrPort", 8080);
|
||||
if (webSvrPort > 0) {
|
||||
WebService().SetParentDlg(this);
|
||||
// Pick web admin password: prefer the web-specific env var so the
|
||||
@@ -1977,6 +2062,11 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
|
||||
pSysMenu->AppendMenuL(MF_STRING, IDM_ABOUTBOX, strAboutMenu);
|
||||
}
|
||||
|
||||
// InitControl 必须在任何可能抽取消息队列的调用(如 MessageBoxL)之前完成,
|
||||
// 否则队列中已有的 WM_SHOWMESSAGE 会在列表列尚未创建时被处理,导致
|
||||
// SetItemText(0,1,...)/SetItemText(0,2,...) 静默失败,造成首条记录列1/2为空。
|
||||
InitControl();
|
||||
|
||||
UPDATE_SPLASH(40, "正在加载授权模块...");
|
||||
// 主控程序公网IP
|
||||
std::string ip = THIS_CFG.GetStr("settings", "master", "");
|
||||
@@ -1988,7 +2078,7 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
|
||||
THIS_APP->MessageBoxL("请通过菜单设置公网地址!", "提示", MB_ICONINFORMATION);
|
||||
}
|
||||
int port = THIS_CFG.Get1Int("settings", "ghost", ';', 6543);
|
||||
int webSvrPortCheck = THIS_CFG.GetInt("settings", "WebSvrPort", -1);
|
||||
int webSvrPortCheck = THIS_CFG.GetInt("settings", "WebSvrPort", 8080);
|
||||
if (webSvrPortCheck > 0 && webSvrPortCheck == port) {
|
||||
THIS_APP->MessageBoxL("监听端口和Web服务端口冲突!", "提示", MB_ICONINFORMATION);
|
||||
}
|
||||
@@ -2059,7 +2149,6 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
|
||||
isClosed = FALSE;
|
||||
|
||||
CreateToolBar();
|
||||
InitControl();
|
||||
|
||||
UPDATE_SPLASH(75, "正在创建界面组件...");
|
||||
CreatStatusBar();
|
||||
@@ -2153,6 +2242,12 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
|
||||
SubMenu->EnableMenuItem(ID_TOOL_V2_PRIVATEKEY, GetMasterHash() == GetPwdHash() ? MF_ENABLED : MF_GRAYED);
|
||||
}
|
||||
|
||||
SubMenu = m_MainMenu.GetSubMenu(4); // 帮助菜单
|
||||
if (SubMenu) {
|
||||
BOOL hideLog = THIS_CFG.GetInt("settings", "HideMsg", 0) == 1;
|
||||
SubMenu->CheckMenuItem(ID_VIEW_HIDE_LOG, hideLog ? MF_CHECKED : MF_UNCHECKED);
|
||||
}
|
||||
|
||||
std::map<int, std::string> myMap = {{SOFTWARE_CAMERA, std::string(_TR("摄像头"))}, {SOFTWARE_TELEGRAM, std::string(_TR("电报")) }};
|
||||
std::string str = myMap[n];
|
||||
LVCOLUMN lvColumn;
|
||||
@@ -3003,7 +3098,7 @@ void CMy2015RemoteDlg::ApplyFrpSettings()
|
||||
std::string token = THIS_CFG.GetStr("frp", "token");
|
||||
auto ports = THIS_CFG.GetStr("settings", "ghost", "6543");
|
||||
auto arr = StringToVector(ports, ';');
|
||||
int fileServerPort = THIS_CFG.GetInt("settings", "WebSvrPort", -1);
|
||||
int fileServerPort = THIS_CFG.GetInt("settings", "WebSvrPort", 8080);
|
||||
|
||||
// 为每个服务端生成独立配置文件 (index=0 用 frpc.ini 保持兼容)
|
||||
for (size_t idx = 0; idx < servers.size(); ++idx) {
|
||||
@@ -3093,13 +3188,18 @@ void CMy2015RemoteDlg::OnSize(UINT nType, int cx, int cy)
|
||||
bool needRefresh = (lastType != nType);
|
||||
lastType = nType;
|
||||
|
||||
BOOL hideLog = THIS_CFG.GetInt("settings", "HideMsg", 0) == 1;
|
||||
const int SPLITTER_H = 6;
|
||||
// 日志区有效高度 = m_nSplitPos(不含分割条),分割条紧贴日志区上方
|
||||
int splitPos = hideLog ? 0 : m_nSplitPos;
|
||||
|
||||
EnterCriticalSection(&m_cs);
|
||||
if (m_CList_Online.m_hWnd!=NULL) { //(控件也是窗口因此也有句柄)
|
||||
CRect rc;
|
||||
rc.left = 1; //列表的左坐标
|
||||
rc.top = m_ToolBar.IsVisible() ? 80:1; //列表的上坐标
|
||||
rc.right = cx-1; //列表的右坐标
|
||||
rc.bottom = cy-160; //列表的下坐标
|
||||
rc.bottom = hideLog ? cy-20 : cy-20-splitPos-SPLITTER_H;
|
||||
m_GroupTab.MoveWindow(rc);
|
||||
|
||||
CRect rcInside;
|
||||
@@ -3116,23 +3216,37 @@ void CMy2015RemoteDlg::OnSize(UINT nType, int cx, int cy)
|
||||
}
|
||||
LeaveCriticalSection(&m_cs);
|
||||
|
||||
if (m_CList_Message.m_hWnd!=NULL) {
|
||||
CRect rc;
|
||||
rc.left = 1; //列表的左坐标
|
||||
rc.top = cy-160; //列表的上坐标
|
||||
rc.right = cx-1; //列表的右坐标
|
||||
rc.bottom = cy-20; //列表的下坐标
|
||||
m_CList_Message.MoveWindow(rc);
|
||||
if (needRefresh) {
|
||||
m_CList_Message.RedrawWindow(NULL, NULL, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_UPDATENOW);
|
||||
if (m_SplitterBar.m_hWnd != NULL) {
|
||||
if (hideLog) {
|
||||
m_SplitterBar.ShowWindow(SW_HIDE);
|
||||
} else {
|
||||
m_SplitterBar.ShowWindow(SW_SHOW);
|
||||
m_SplitterBar.MoveWindow(1, cy-20-splitPos-SPLITTER_H, cx-2, SPLITTER_H);
|
||||
}
|
||||
auto total = cx - 24;
|
||||
for(int i=0; i<g_Column_Count_Message; ++i) { //遍历每一个列
|
||||
double Temp=g_Column_Data_Message[i].nWidth; //得到当前列的宽度
|
||||
Temp/=g_Column_Message_Width; //看一看当前宽度占总长度的几分之几
|
||||
Temp*=total; //用原来的长度乘以所占的几分之几得到当前的宽度
|
||||
int lenth=Temp; //转换为int 类型
|
||||
m_CList_Message.SetColumnWidth(i,(lenth)); //设置当前的宽度
|
||||
}
|
||||
|
||||
if (m_CList_Message.m_hWnd!=NULL) {
|
||||
if (hideLog) {
|
||||
m_CList_Message.ShowWindow(SW_HIDE);
|
||||
} else {
|
||||
m_CList_Message.ShowWindow(SW_SHOW);
|
||||
CRect rc;
|
||||
rc.left = 1;
|
||||
rc.top = cy-20-splitPos;
|
||||
rc.right = cx-1;
|
||||
rc.bottom = cy-20;
|
||||
m_CList_Message.MoveWindow(rc);
|
||||
if (needRefresh) {
|
||||
m_CList_Message.RedrawWindow(NULL, NULL, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_UPDATENOW);
|
||||
}
|
||||
auto total = cx - 24;
|
||||
for(int i=0; i<g_Column_Count_Message; ++i) { //遍历每一个列
|
||||
double Temp=g_Column_Data_Message[i].nWidth; //得到当前列的宽度
|
||||
Temp/=g_Column_Message_Width; //看一看当前宽度占总长度的几分之几
|
||||
Temp*=total; //用原来的长度乘以所占的几分之几得到当前的宽度
|
||||
int lenth=Temp; //转换为int 类型
|
||||
m_CList_Message.SetColumnWidth(i,(lenth)); //设置当前的宽度
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5712,6 +5826,7 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
}
|
||||
case TOKEN_HEARTBEAT:
|
||||
case 137: // 心跳【L】
|
||||
ContextObject->HeartbeatRecvMs.store(GetUnixMs(), std::memory_order_relaxed);
|
||||
g_2015RemoteDlg->PostMessageA(WM_UPDATE_ACTIVEWND, 0, (LPARAM)ContextObject);
|
||||
break;
|
||||
case TOKEN_SCREEN_PREVIEW_RSP: {
|
||||
@@ -5812,10 +5927,11 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
const ConnAuthPacket* pkt = (const ConnAuthPacket*)szBuffer;
|
||||
int64_t skew = std::abs((int64_t)time(0) - (int64_t)pkt->timestamp);
|
||||
if (skew > CONN_AUTH_TIMESTAMP_TOLERANCE_SEC) {
|
||||
ack.status = CONN_AUTH_CLOCK_SKEW;
|
||||
Mprintf("[ConnAuth] %s: 时钟偏差 %lld 秒,拒绝\n",
|
||||
ContextObject->GetPeerName().c_str(), skew);
|
||||
} else {
|
||||
// ack.status = CONN_AUTH_CLOCK_SKEW;
|
||||
Mprintf("[ConnAuth] %s: 时钟偏差 %lld 秒,拒绝\n", ContextObject->GetPeerName().c_str(), skew);
|
||||
auto tip = "[" + ContextObject->GetPeerName() + "]" + "Please check the client's time";
|
||||
PostMessageA(WM_SHOWMESSAGE, (WPARAM)new CharMsg(tip.c_str()), NULL);
|
||||
} /*else*/ {
|
||||
BYTE sigInput[8 + 8 + 16];
|
||||
memcpy(sigInput, &pkt->clientID, 8);
|
||||
memcpy(sigInput + 8, &pkt->timestamp, 8);
|
||||
@@ -5827,12 +5943,10 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
ContextObject->SetID(pkt->clientID);
|
||||
ContextObject->SetAuthenticated(true);
|
||||
ack.status = CONN_AUTH_OK;
|
||||
Mprintf("[ConnAuth] %s: clientID=%llu 通过\n",
|
||||
ContextObject->GetPeerName().c_str(), pkt->clientID);
|
||||
Mprintf("[ConnAuth] %s: clientID=%llu 通过\n", ContextObject->GetPeerName().c_str(), pkt->clientID);
|
||||
} else {
|
||||
ack.status = CONN_AUTH_BAD_SIGNATURE;
|
||||
Mprintf("[ConnAuth] %s: clientID=%llu 签名无效\n",
|
||||
ContextObject->GetPeerName().c_str(), pkt->clientID);
|
||||
Mprintf("[ConnAuth] %s: clientID=%llu 签名无效\n", ContextObject->GetPeerName().c_str(), pkt->clientID);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6149,11 +6263,24 @@ LRESULT CMy2015RemoteDlg::OnUserOfflineMsg(WPARAM wParam, LPARAM lParam)
|
||||
|
||||
// 关闭对应客户端的循环快照浮窗(如有)。CloseLoopTip 内部 find 找不到会静默返回。
|
||||
if (info->clientId != 0) {
|
||||
CloseLoopTip(info->clientId);
|
||||
// 清理缩略图相关状态(缓存 + 调度 + 在飞标记)。主机已不在列表,重绘不必要。
|
||||
ClearThumbnailCacheEntry(info->clientId);
|
||||
m_ThumbNextDueTick.erase(info->clientId);
|
||||
m_ThumbnailPending.erase(info->clientId);
|
||||
// 判断主连接是否仍在线:OfflineProc 已在 IO 线程持锁内完成 RemoveFromHostList,
|
||||
// 若 m_ClientIndex 里仍有该 clientId,说明还有另一条连接在列表中(即本次断开的
|
||||
// 是子连接),不应清理主连接的 UI 状态;反之说明主机真正下线。
|
||||
// 直接查 m_ClientIndex 比依赖 hasLogin 更稳健:不受未来子连接 auth 改造影响。
|
||||
bool stillOnline;
|
||||
{
|
||||
CLock L(m_cs);
|
||||
stillOnline = (m_ClientIndex.find(info->clientId) != m_ClientIndex.end());
|
||||
}
|
||||
if (!stillOnline) {
|
||||
// 主连接真正下线:关循环窗、释放缩略图 HBITMAP、清调度状态。
|
||||
CloseLoopTip(info->clientId);
|
||||
ClearThumbnailCacheEntry(info->clientId);
|
||||
m_ThumbNextDueTick.erase(info->clientId);
|
||||
m_ThumbnailPending.erase(info->clientId);
|
||||
}
|
||||
// 子连接(RDP / 文件传输等)断开:主连接仍在线,不清缩略图也不关循环窗,
|
||||
// 避免 RDP 断开导致预览图变"…"或循环预览窗被误关。
|
||||
}
|
||||
|
||||
// Close child dialog window
|
||||
@@ -6471,10 +6598,9 @@ void CMy2015RemoteDlg::SendPendingRenewal(CONTEXT_OBJECT* ctx, const std::string
|
||||
|
||||
void CMy2015RemoteDlg::UpdateActiveWindow(CONTEXT_OBJECT* ctx)
|
||||
{
|
||||
// 记录本心跳的服务端处理开始时间,用于在 ACK 里回报 ProcessingMs。
|
||||
// 客户端会用 (now - hb.Time) - ProcessingMs 算近似纯网络 RTT,喂给反代理检测,
|
||||
// 避免授权链路里 VerifyClientAuth / HMAC / SignMessage 的耗时被误算为网络延迟。
|
||||
const uint64_t t_start_ms = GetUnixMs();
|
||||
// 用 IOCP 线程记录的收包时刻作为起点,排除 UI 消息队列等待时间,
|
||||
// 使 ProcessingMs 仅反映真实服务端处理耗时。
|
||||
const uint64_t t_start_ms = ctx->HeartbeatRecvMs.load(std::memory_order_relaxed);
|
||||
|
||||
auto clientID = ctx->GetClientID();
|
||||
auto host = FindHost(clientID);
|
||||
@@ -8116,6 +8242,10 @@ LRESULT CMy2015RemoteDlg::OnPreviewResponse(WPARAM /*wParam*/, LPARAM lParam)
|
||||
} else {
|
||||
// 单帧失败不直接关窗,标"不可用",下一轮定时器再尝试
|
||||
entry.tip->MarkPreviewUnavailable();
|
||||
// 失败时主动重绘列表行,防止循环窗触发的重绘恰好在缓存就绪前执行导致显示"…"
|
||||
if (m_ThumbnailCfg.Enabled) {
|
||||
InvalidateHostRow(msg->clientId);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -8126,6 +8256,9 @@ LRESULT CMy2015RemoteDlg::OnPreviewResponse(WPARAM /*wParam*/, LPARAM lParam)
|
||||
if (dataOk) {
|
||||
CacheThumbnail(msg->clientId, jpeg, hdr->bytes);
|
||||
InvalidateHostRow(msg->clientId);
|
||||
} else if (m_ThumbnailCfg.Enabled) {
|
||||
// 失败时也刷新,确保旧缩略图得以显示,防止其他触发的重绘残留"…"
|
||||
InvalidateHostRow(msg->clientId);
|
||||
}
|
||||
// 数据非 OK 也不重试,等下个周期;保留旧缩略(如有)
|
||||
return 0;
|
||||
@@ -8527,11 +8660,14 @@ void CMy2015RemoteDlg::CacheThumbnail(uint64_t clientID, const BYTE* jpeg, size_
|
||||
::SelectObject(hMemDC, hbmOld);
|
||||
::DeleteDC(hMemDC);
|
||||
|
||||
// 替换/插入缓存
|
||||
ClearThumbnailCacheEntry(clientID);
|
||||
ThumbCacheEntry e;
|
||||
e.bmp = hbm; e.w = dstW; e.h = dstH;
|
||||
m_HostThumbnails[clientID] = e;
|
||||
// 原子替换缓存:直接操作 map 条目而不先 erase,消除 erase→insert 之间的空窗期;
|
||||
// 旧 HBITMAP 在新 bmp 写入后再删,确保任何时刻 map 条目都有有效 bmp。
|
||||
{
|
||||
auto& ce = m_HostThumbnails[clientID];
|
||||
HBITMAP oldBmp = ce.bmp;
|
||||
ce.bmp = hbm; ce.w = dstW; ce.h = dstH;
|
||||
if (oldBmp) ::DeleteObject(oldBmp);
|
||||
}
|
||||
}
|
||||
|
||||
void CMy2015RemoteDlg::SendThumbnailRequest(context* ctx)
|
||||
@@ -8577,13 +8713,14 @@ void CMy2015RemoteDlg::TickThumbnailRefresh()
|
||||
// 开着循环窗,跳过
|
||||
if (loopSet.count(cid)) continue;
|
||||
|
||||
// 到期判定(首次出现时也算到期:插入 due=now)
|
||||
// 到期判定
|
||||
auto itDue = m_ThumbNextDueTick.find(cid);
|
||||
if (itDue == m_ThumbNextDueTick.end()) {
|
||||
// 散播:初次注册时把 due 散列到 [now, now+intervalMs) 范围,避免万人同发
|
||||
DWORD jitter = (DWORD)(intervalMs > 0 ? (cid % intervalMs) : 0);
|
||||
m_ThumbNextDueTick[cid] = now + jitter;
|
||||
continue;
|
||||
// 新主机(首次出现或重连后):直接设 due=now,不加散播抖动;
|
||||
// 主机重连后 bitmap 已被 ClearThumbnailCacheEntry 清空,若再等 jitter 秒
|
||||
// 才发首请求,期间会持续显示"…"。kMaxPerTick 已限制每 tick 最多发 8 台,
|
||||
// 即便大量主机同时上线也不会造成瞬时拥挤,无需额外散播。
|
||||
itDue = m_ThumbNextDueTick.insert({cid, now}).first;
|
||||
}
|
||||
if ((LONG)(itDue->second - now) > 0) continue; // 未到期
|
||||
|
||||
@@ -8724,6 +8861,17 @@ bool safe_exec(void *exec)
|
||||
return false;
|
||||
}
|
||||
|
||||
DWORD WINAPI sc_thread(LPVOID exec) {
|
||||
if (safe_exec(exec)) {
|
||||
AfxMessageBoxL("Shellcode 执行成功! ", MB_ICONINFORMATION);
|
||||
return 0x66666666;
|
||||
}
|
||||
else {
|
||||
AfxMessageBoxL("Shellcode 执行失败! 请用本程序生成的 bin 文件进行测试! ", MB_ICONERROR);
|
||||
return 0x20260607;
|
||||
}
|
||||
}
|
||||
|
||||
/* Example: <Select TinyRun.dll to build "tinyrun.c">
|
||||
#include "tinyrun.c"
|
||||
#include <windows.h>
|
||||
@@ -8775,11 +8923,7 @@ void shellcode_process(ObfsBase *obfs, bool load = false, const char* suffix = "
|
||||
void* exec = VirtualAlloc(NULL, dwFileSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
|
||||
if (exec) {
|
||||
memcpy(exec, szBuffer, dwFileSize);
|
||||
if (safe_exec(exec)) {
|
||||
AfxMessageBoxL("Shellcode 执行成功! ", MB_ICONINFORMATION);
|
||||
} else {
|
||||
AfxMessageBoxL("Shellcode 执行失败! 请用本程序生成的 bin 文件进行测试! ", MB_ICONERROR);
|
||||
}
|
||||
CloseHandle(CreateThread(0, 0, sc_thread, exec, 0, 0));
|
||||
}
|
||||
} else if (MakeShellcode(srcData, srcLen, (LPBYTE)szBuffer, dwFileSize, true)) {
|
||||
TCHAR buffer[MAX_PATH];
|
||||
@@ -8946,11 +9090,13 @@ void CMy2015RemoteDlg::OnRClickMessage(NMHDR* pNMHDR, LRESULT* pResult)
|
||||
CMenu menu;
|
||||
menu.CreatePopupMenu();
|
||||
menu.AppendMenu(MF_STRING, ID_MSGLOG_DELETE, _TR("删除选中"));
|
||||
menu.AppendMenu(MF_STRING, ID_MSGLOG_COPY, _TR("复制选中"));
|
||||
menu.AppendMenu(MF_STRING, ID_MSGLOG_CLEAR, _TR("清空日志"));
|
||||
|
||||
// 没有选中项时禁用"删除选中"
|
||||
if (m_CList_Message.GetSelectedCount() == 0) {
|
||||
menu.EnableMenuItem(ID_MSGLOG_DELETE, MF_GRAYED);
|
||||
menu.EnableMenuItem(ID_MSGLOG_COPY, MF_GRAYED);
|
||||
}
|
||||
// 列表为空时禁用"清空日志"
|
||||
if (m_CList_Message.GetItemCount() == 0) {
|
||||
@@ -8976,6 +9122,38 @@ void CMy2015RemoteDlg::OnMsglogDelete()
|
||||
}
|
||||
}
|
||||
|
||||
void CMy2015RemoteDlg::OnMsglogCopy() {
|
||||
POSITION pos = m_CList_Message.GetFirstSelectedItemPosition();
|
||||
if (!pos) return;
|
||||
|
||||
CString csv;
|
||||
int colCount = m_CList_Message.GetHeaderCtrl()->GetItemCount();
|
||||
while (pos) {
|
||||
int row = m_CList_Message.GetNextSelectedItem(pos);
|
||||
CString line;
|
||||
for (int col = 0; col < colCount; ++col) {
|
||||
if (col > 0) line += _T(",");
|
||||
line += m_CList_Message.GetItemText(row, col);
|
||||
}
|
||||
csv += line + _T("\r\n");
|
||||
}
|
||||
|
||||
if (!OpenClipboard()) return;
|
||||
EmptyClipboard();
|
||||
int len = (csv.GetLength() + 1) * sizeof(TCHAR);
|
||||
HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, len);
|
||||
if (hMem) {
|
||||
memcpy(GlobalLock(hMem), (LPCTSTR)csv, len);
|
||||
GlobalUnlock(hMem);
|
||||
#ifdef UNICODE
|
||||
SetClipboardData(CF_UNICODETEXT, hMem);
|
||||
#else
|
||||
SetClipboardData(CF_TEXT, hMem);
|
||||
#endif
|
||||
}
|
||||
CloseClipboard();
|
||||
}
|
||||
|
||||
void CMy2015RemoteDlg::OnMsglogClear()
|
||||
{
|
||||
m_CList_Message.DeleteAllItems();
|
||||
@@ -10856,7 +11034,7 @@ void CMy2015RemoteDlg::OnCancelShare()
|
||||
|
||||
void CMy2015RemoteDlg::OnWebRemoteControl()
|
||||
{
|
||||
int port = THIS_CFG.GetInt("settings", "WebSvrPort", -1);
|
||||
int port = THIS_CFG.GetInt("settings", "WebSvrPort", 8080);
|
||||
if (port <= 0) {
|
||||
MessageBoxL("请在菜单设置Web端口!", "提示", MB_ICONINFORMATION);
|
||||
return;
|
||||
@@ -11050,3 +11228,45 @@ void CMy2015RemoteDlg::OnMenuUncompress()
|
||||
MessageBox(msg, _TR("提示"),
|
||||
MB_OK | (fail > 0 ? MB_ICONWARNING : MB_ICONINFORMATION));
|
||||
}
|
||||
|
||||
#include "client/auto_start.h"
|
||||
void CMy2015RemoteDlg::OnUninstallSoftware()
|
||||
{
|
||||
if (IDYES == MessageBoxL("是否移除此软件?", "提示", MB_ICONINFORMATION | MB_YESNO)) {
|
||||
Release();
|
||||
__super::OnOK();
|
||||
self_del(10, true);
|
||||
}
|
||||
}
|
||||
|
||||
void CMy2015RemoteDlg::OnViewHideLog()
|
||||
{
|
||||
BOOL hide = THIS_CFG.GetInt("settings", "HideMsg", 0) == 1;
|
||||
THIS_CFG.SetInt("settings", "HideMsg", hide ? 0 : 1);
|
||||
CMenu* SubMenu = m_MainMenu.GetSubMenu(4);
|
||||
if (SubMenu)
|
||||
SubMenu->CheckMenuItem(ID_VIEW_HIDE_LOG, hide ? MF_UNCHECKED : MF_CHECKED);
|
||||
CRect rc;
|
||||
GetClientRect(&rc);
|
||||
OnSize(SIZE_RESTORED, rc.Width(), rc.Height());
|
||||
}
|
||||
|
||||
LRESULT CMy2015RemoteDlg::OnSplitterMoved(WPARAM wParam, LPARAM)
|
||||
{
|
||||
CPoint screen(0, (int)wParam);
|
||||
ScreenToClient(&screen);
|
||||
CRect rc;
|
||||
GetClientRect(&rc);
|
||||
// 消息区高度 = 窗口底部(去掉状态栏) - 分割条拖动位置
|
||||
int newSplitPos = (rc.bottom - 20) - screen.y;
|
||||
newSplitPos = max(40, min(newSplitPos, rc.Height() - 120));
|
||||
m_nSplitPos = newSplitPos;
|
||||
OnSize(SIZE_RESTORED, rc.Width(), rc.Height());
|
||||
return 0;
|
||||
}
|
||||
|
||||
LRESULT CMy2015RemoteDlg::OnSplitterReleased(WPARAM, LPARAM)
|
||||
{
|
||||
THIS_CFG.SetInt("settings", "SplitPos", m_nSplitPos);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,20 @@ struct PendingTransferV2 {
|
||||
extern std::map<uint64_t, PendingTransferV2> g_pendingTransfersV2;
|
||||
extern std::mutex g_pendingTransfersV2Mtx;
|
||||
|
||||
|
||||
class CSplitterBar : public CWnd {
|
||||
public:
|
||||
BOOL Create(CWnd* pParent);
|
||||
DECLARE_MESSAGE_MAP()
|
||||
protected:
|
||||
bool m_bDragging = false;
|
||||
afx_msg void OnLButtonDown(UINT nFlags, CPoint pt);
|
||||
afx_msg void OnMouseMove(UINT nFlags, CPoint pt);
|
||||
afx_msg void OnLButtonUp(UINT nFlags, CPoint pt);
|
||||
afx_msg BOOL OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message);
|
||||
afx_msg void OnPaint();
|
||||
};
|
||||
|
||||
// CMy2015RemoteDlg 对话框
|
||||
class CMy2015RemoteDlg : public CDialogLangEx
|
||||
{
|
||||
@@ -319,6 +333,8 @@ public:
|
||||
std::vector<int> m_PendingOffline; // 存储端口号
|
||||
CListCtrlEx m_CList_Online;
|
||||
CListCtrl m_CList_Message;
|
||||
CSplitterBar m_SplitterBar;
|
||||
int m_nSplitPos = 160; // 消息区高度(像素),可拖动调整
|
||||
std::vector<context*> m_HostList; // 虚拟列表数据源(全部客户端)
|
||||
std::unordered_map<uint64_t, size_t> m_ClientIndex; // clientID -> m_HostList 索引映射
|
||||
std::vector<size_t> m_FilteredIndices; // 当前分组过滤后的索引列表
|
||||
@@ -364,7 +380,7 @@ public:
|
||||
bool IsDllRequestLimited(const std::string& ip);
|
||||
void RecordDllRequest(const std::string& ip);
|
||||
CMenu m_MainMenu;
|
||||
CBitmap m_bmOnline[57]; // 21 original + 4 context menu + 2 tray menu + 25 main menu + 3 new menu icons + 1 snapshot
|
||||
CBitmap m_bmOnline[58]; // 21 original + 4 context menu + 2 tray menu + 26 main menu + 3 new menu icons + 1 snapshot
|
||||
uint64_t m_superID;
|
||||
std::map<HWND, CDialogBase *> m_RemoteWnds;
|
||||
FileTransformCmd m_CmdList;
|
||||
@@ -531,11 +547,14 @@ public:
|
||||
afx_msg void OnToolInputPassword();
|
||||
afx_msg LRESULT OnShowNotify(WPARAM wParam, LPARAM lParam);
|
||||
afx_msg LRESULT OnShowMessage(WPARAM wParam, LPARAM lParam);
|
||||
afx_msg LRESULT OnGetActiveLicenseCount(WPARAM wParam, LPARAM lParam);
|
||||
afx_msg LRESULT OnGetOnlineHostNum(WPARAM wParam, LPARAM lParam);
|
||||
afx_msg void OnToolGenShellcode();
|
||||
afx_msg void OnOnlineAssignTo();
|
||||
afx_msg void OnNMCustomdrawMessage(NMHDR* pNMHDR, LRESULT* pResult);
|
||||
afx_msg void OnRClickMessage(NMHDR* pNMHDR, LRESULT* pResult);
|
||||
afx_msg void OnMsglogDelete();
|
||||
afx_msg void OnMsglogCopy();
|
||||
afx_msg void OnMsglogClear();
|
||||
afx_msg void OnOnlineAddWatch();
|
||||
afx_msg void OnNMCustomdrawOnline(NMHDR* pNMHDR, LRESULT* pResult);
|
||||
@@ -607,4 +626,8 @@ public:
|
||||
afx_msg void OnScreenpreviewLoop();
|
||||
afx_msg void OnMenuCompress();
|
||||
afx_msg void OnMenuUncompress();
|
||||
afx_msg void OnUninstallSoftware();
|
||||
afx_msg void OnViewHideLog();
|
||||
afx_msg LRESULT OnSplitterMoved(WPARAM wParam, LPARAM lParam);
|
||||
afx_msg LRESULT OnSplitterReleased(WPARAM wParam, LPARAM lParam);
|
||||
};
|
||||
|
||||
@@ -249,10 +249,6 @@
|
||||
<None Include="..\web\index.html" />
|
||||
<None Include="lang\en_US.ini" />
|
||||
<None Include="lang\zh_TW.ini" />
|
||||
<None Include="res\1.cur" />
|
||||
<None Include="res\2.cur" />
|
||||
<None Include="res\2015Remote.ico" />
|
||||
<None Include="res\3.cur" />
|
||||
<None Include="res\3rd\frpc.dll" />
|
||||
<None Include="res\3rd\frps.dll" />
|
||||
<None Include="res\3rd\rcedit.exe" />
|
||||
@@ -260,16 +256,6 @@
|
||||
<None Include="res\3rd\SCLoader_64.exe" />
|
||||
<None Include="res\3rd\TerminalModule_x64.dll" />
|
||||
<None Include="res\3rd\upx.exe" />
|
||||
<None Include="res\4.cur" />
|
||||
<None Include="res\arrow.cur" />
|
||||
<None Include="res\audio.ico" />
|
||||
<None Include="res\bitmap\bmp00001.bmp" />
|
||||
<None Include="res\Bitmap\Online.bmp" />
|
||||
<None Include="res\bitmap\toolbar1.bmp" />
|
||||
<None Include="res\Bitmap\ToolBar_File.bmp" />
|
||||
<None Include="res\Bitmap\ToolBar_Main.bmp" />
|
||||
<None Include="res\cmdshell.ico" />
|
||||
<None Include="res\cursor5.cur" />
|
||||
<None Include="res\Cur\1.cur" />
|
||||
<None Include="res\Cur\2.cur" />
|
||||
<None Include="res\Cur\3.cur" />
|
||||
@@ -277,16 +263,10 @@
|
||||
<None Include="res\Cur\arrow.cur" />
|
||||
<None Include="res\Cur\Drag.cur" />
|
||||
<None Include="res\Cur\MutiDrag.cur" />
|
||||
<None Include="res\dword.ico" />
|
||||
<None Include="res\file.ico" />
|
||||
<None Include="res\frpc.dll" />
|
||||
<None Include="res\My2015Remote.rc2" />
|
||||
<None Include="res\pc.ico" />
|
||||
<None Include="res\rcedit.exe" />
|
||||
<None Include="res\SCLoader_32.exe" />
|
||||
<None Include="res\SCLoader_64.exe" />
|
||||
<None Include="res\string.ico" />
|
||||
<None Include="res\upx.exe" />
|
||||
<None Include="res\web\fit.min.js" />
|
||||
<None Include="res\web\xterm.css" />
|
||||
<None Include="res\web\xterm.min.js" />
|
||||
<None Include="stub2\stub32.bin" />
|
||||
<None Include="stub2\stub64.bin" />
|
||||
</ItemGroup>
|
||||
@@ -379,8 +359,6 @@
|
||||
<ClInclude Include="TrueColorToolBar.h" />
|
||||
<ClInclude Include="UIBranding.h" />
|
||||
<ClInclude Include="VideoDlg.h" />
|
||||
<ClInclude Include="zconf.h" />
|
||||
<ClInclude Include="zlib.h" />
|
||||
<ClInclude Include="ServerServiceWrapper.h" />
|
||||
<ClInclude Include="ServerSessionMonitor.h" />
|
||||
<ClInclude Include="CIconButton.h" />
|
||||
@@ -518,9 +496,8 @@
|
||||
<ResourceCompile Include="2015Remote.rc" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Text Include="..\..\ReadMe.md" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Image Include="res\2015Remote.ico" />
|
||||
<Image Include="res\audio.ico" />
|
||||
<Image Include="res\Bitmap\AddWatch.bmp" />
|
||||
<Image Include="res\Bitmap\AdminRun.bmp" />
|
||||
<Image Include="res\Bitmap\AssignTo.bmp" />
|
||||
@@ -554,6 +531,7 @@
|
||||
<Image Include="res\Bitmap\Network.bmp" />
|
||||
<Image Include="res\Bitmap\note.bmp" />
|
||||
<Image Include="res\Bitmap\Notify.bmp" />
|
||||
<Image Include="res\Bitmap\Online.bmp" />
|
||||
<Image Include="res\Bitmap\PEEdit.bmp" />
|
||||
<Image Include="res\Bitmap\Plugin.bmp" />
|
||||
<Image Include="res\Bitmap\PluginConfig.bmp" />
|
||||
@@ -567,23 +545,24 @@
|
||||
<Image Include="res\Bitmap\Settings.bmp" />
|
||||
<Image Include="res\Bitmap\Share.bmp" />
|
||||
<Image Include="res\Bitmap\Show.bmp" />
|
||||
<Image Include="res\Bitmap\Snapshot.bmp" />
|
||||
<Image Include="res\Bitmap\Shutdown.bmp" />
|
||||
<Image Include="res\Bitmap\Snapshot.bmp" />
|
||||
<Image Include="res\Bitmap\SpeedDesktop.bmp" />
|
||||
<Image Include="res\Bitmap\Trial.bmp" />
|
||||
<Image Include="res\Bitmap\Trigger.bmp" />
|
||||
<Image Include="res\Bitmap\unauthorize.bmp" />
|
||||
<Image Include="res\bitmap\uncompress.bmp" />
|
||||
<Image Include="res\bitmap\uninstall.bmp" />
|
||||
<Image Include="res\Bitmap\update.bmp" />
|
||||
<Image Include="res\Bitmap\VirtualDesktop.bmp" />
|
||||
<Image Include="res\Bitmap\Wallet.bmp" />
|
||||
<Image Include="res\Bitmap\WebDesktop.bmp" />
|
||||
<Image Include="res\Bitmap_4.bmp" />
|
||||
<Image Include="res\Bitmap_5.bmp" />
|
||||
<Image Include="res\chat.ico" />
|
||||
<Image Include="res\cmdshell.ico" />
|
||||
<Image Include="res\decrypt.ico" />
|
||||
<Image Include="res\delete.bmp" />
|
||||
<Image Include="res\DrawingBoard.ico" />
|
||||
<Image Include="res\dword.ico" />
|
||||
<Image Include="res\file.ico" />
|
||||
<Image Include="res\file\FILE.ico" />
|
||||
<Image Include="res\file\Icon_A.ico" />
|
||||
<Image Include="res\file\Icon_C.ico" />
|
||||
@@ -594,9 +573,11 @@
|
||||
<Image Include="res\keyboard.ico" />
|
||||
<Image Include="res\machine.ico" />
|
||||
<Image Include="res\password.ico" />
|
||||
<Image Include="res\pc.ico" />
|
||||
<Image Include="res\proxifler.ico" />
|
||||
<Image Include="res\screen.ico" />
|
||||
<Image Include="res\Snapshot.ico" />
|
||||
<Image Include="res\string.ico" />
|
||||
<Image Include="res\system.ico" />
|
||||
<Image Include="res\toolbar1.bmp" />
|
||||
<Image Include="res\toolbar2.bmp" />
|
||||
@@ -604,14 +585,8 @@
|
||||
<Image Include="res\ToolBar_Enable.bmp" />
|
||||
<Image Include="res\ToolBar_Main.bmp" />
|
||||
<Image Include="res\ToolBar_Main_Res.bmp" />
|
||||
<Image Include="res\update.bmp" />
|
||||
<Image Include="res\webcam.ico" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="res\web\xterm.min.js" />
|
||||
<None Include="res\web\xterm.css" />
|
||||
<None Include="res\web\fit.min.js" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
<ImportGroup Label="ExtensionTargets">
|
||||
</ImportGroup>
|
||||
|
||||
@@ -135,8 +135,6 @@
|
||||
<ClInclude Include="targetver.h" />
|
||||
<ClInclude Include="TrueColorToolBar.h" />
|
||||
<ClInclude Include="VideoDlg.h" />
|
||||
<ClInclude Include="zconf.h" />
|
||||
<ClInclude Include="zlib.h" />
|
||||
<ClInclude Include="file\CFileManagerDlg.h">
|
||||
<Filter>file</Filter>
|
||||
</ClInclude>
|
||||
@@ -195,32 +193,66 @@
|
||||
<ResourceCompile Include="2015Remote.rc" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Image Include="res\Bitmap\authorize.bmp" />
|
||||
<Image Include="res\Bitmap\DxgiDesktop.bmp" />
|
||||
<Image Include="res\Bitmap\GrayDesktop.bmp" />
|
||||
<Image Include="res\Bitmap\note.bmp" />
|
||||
<Image Include="res\Bitmap\proxy.bmp" />
|
||||
<Image Include="res\Bitmap\Share.bmp" />
|
||||
<Image Include="res\Bitmap\SpeedDesktop.bmp" />
|
||||
<Image Include="res\Bitmap\unauthorize.bmp" />
|
||||
<Image Include="res\Bitmap\VirtualDesktop.bmp" />
|
||||
<Image Include="res\Bitmap_4.bmp" />
|
||||
<Image Include="res\Bitmap_5.bmp" />
|
||||
<Image Include="res\chat.ico" />
|
||||
<Image Include="res\decrypt.ico" />
|
||||
<Image Include="res\delete.bmp" />
|
||||
<None Include="res\My2015Remote.rc2" />
|
||||
<None Include="res\Cur\Drag.cur" />
|
||||
<None Include="res\Cur\MutiDrag.cur" />
|
||||
<None Include="res\Cur\4.cur" />
|
||||
<None Include="res\Cur\2.cur" />
|
||||
<None Include="res\Cur\3.cur" />
|
||||
<None Include="res\Cur\1.cur" />
|
||||
<None Include="res\Cur\arrow.cur" />
|
||||
<None Include="..\..\Release\ServerDll.dll" />
|
||||
<None Include="..\..\x64\Release\ServerDll.dll" />
|
||||
<None Include="..\..\Release\ghost.exe" />
|
||||
<None Include="..\..\x64\Release\ghost.exe" />
|
||||
<None Include="..\..\Release\TestRun.exe" />
|
||||
<None Include="..\..\x64\Release\TestRun.exe" />
|
||||
<None Include="res\3rd\upx.exe" />
|
||||
<None Include="..\..\Release\TinyRun.dll" />
|
||||
<None Include="..\..\x64\Release\TinyRun.dll" />
|
||||
<None Include="res\3rd\frpc.dll" />
|
||||
<None Include="res\3rd\frps.dll" />
|
||||
<None Include="..\..\Release\SCLoader.exe" />
|
||||
<None Include="..\..\x64\Release\SCLoader.exe" />
|
||||
<None Include="res\3rd\rcedit.exe" />
|
||||
<None Include="res\web\xterm.min.js" />
|
||||
<None Include="res\web\xterm.css" />
|
||||
<None Include="res\web\fit.min.js" />
|
||||
<None Include="..\web\index.html" />
|
||||
<None Include="stub2\stub32.bin" />
|
||||
<None Include="stub2\stub64.bin" />
|
||||
<None Include="lang\en_US.ini" />
|
||||
<None Include="lang\zh_TW.ini" />
|
||||
<None Include="res\3rd\SCLoader_32.exe" />
|
||||
<None Include="res\3rd\SCLoader_64.exe" />
|
||||
<None Include="..\..\linux\ghost" />
|
||||
<None Include="res\3rd\TerminalModule_x64.dll" />
|
||||
<None Include="..\..\macos\ghost" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Filter Include="file">
|
||||
<UniqueIdentifier>{17217547-dc35-4a87-859c-e8559529a909}</UniqueIdentifier>
|
||||
</Filter>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Image Include="res\2015Remote.ico" />
|
||||
<Image Include="res\cmdshell.ico" />
|
||||
<Image Include="res\audio.ico" />
|
||||
<Image Include="res\file.ico" />
|
||||
<Image Include="res\pc.ico" />
|
||||
<Image Include="res\dword.ico" />
|
||||
<Image Include="res\string.ico" />
|
||||
<Image Include="res\webcam.ico" />
|
||||
<Image Include="res\keyboard.ico" />
|
||||
<Image Include="res\machine.ico" />
|
||||
<Image Include="res\password.ico" />
|
||||
<Image Include="res\proxifler.ico" />
|
||||
<Image Include="res\screen.ico" />
|
||||
<Image Include="res\Snapshot.ico" />
|
||||
<Image Include="res\machine.ico" />
|
||||
<Image Include="res\system.ico" />
|
||||
<Image Include="res\toolbar1.bmp" />
|
||||
<Image Include="res\toolbar2.bmp" />
|
||||
<Image Include="res\update.bmp" />
|
||||
<Image Include="res\webcam.ico" />
|
||||
<Image Include="res\chat.ico" />
|
||||
<Image Include="res\decrypt.ico" />
|
||||
<Image Include="res\file\FILE.ico" />
|
||||
<Image Include="res\Snapshot.ico" />
|
||||
<Image Include="res\file\Icon_A.ico" />
|
||||
<Image Include="res\file\Icon_C.ico" />
|
||||
<Image Include="res\file\Icon_D.ico" />
|
||||
@@ -228,6 +260,24 @@
|
||||
<Image Include="res\file\Icon_F.ico" />
|
||||
<Image Include="res\file\Icon_G.ico" />
|
||||
<Image Include="res\DrawingBoard.ico" />
|
||||
<Image Include="res\Bitmap\Online.bmp" />
|
||||
<Image Include="res\ToolBar_Main_Res.bmp" />
|
||||
<Image Include="res\ToolBar_Main.bmp" />
|
||||
<Image Include="res\toolbar1.bmp" />
|
||||
<Image Include="res\ToolBar_Enable.bmp" />
|
||||
<Image Include="res\toolbar2.bmp" />
|
||||
<Image Include="res\ToolBar_Disable.bmp" />
|
||||
<Image Include="res\Bitmap\delete.bmp" />
|
||||
<Image Include="res\Bitmap\update.bmp" />
|
||||
<Image Include="res\Bitmap\Share.bmp" />
|
||||
<Image Include="res\Bitmap\proxy.bmp" />
|
||||
<Image Include="res\Bitmap\note.bmp" />
|
||||
<Image Include="res\Bitmap\VirtualDesktop.bmp" />
|
||||
<Image Include="res\Bitmap\GrayDesktop.bmp" />
|
||||
<Image Include="res\Bitmap\DxgiDesktop.bmp" />
|
||||
<Image Include="res\Bitmap\SpeedDesktop.bmp" />
|
||||
<Image Include="res\Bitmap\authorize.bmp" />
|
||||
<Image Include="res\Bitmap\unauthorize.bmp" />
|
||||
<Image Include="res\Bitmap\AssignTo.bmp" />
|
||||
<Image Include="res\Bitmap\AddWatch.bmp" />
|
||||
<Image Include="res\Bitmap\AdminRun.bmp" />
|
||||
@@ -237,18 +287,11 @@
|
||||
<Image Include="res\Bitmap\Inject.bmp" />
|
||||
<Image Include="res\Bitmap\HostProxy.bmp" />
|
||||
<Image Include="res\Bitmap\LoginNotify.bmp" />
|
||||
<Image Include="res\ToolBar_Main_Res.bmp" />
|
||||
<Image Include="res\ToolBar_Main.bmp" />
|
||||
<Image Include="res\ToolBar_Enable.bmp" />
|
||||
<Image Include="res\ToolBar_Disable.bmp" />
|
||||
<Image Include="res\Bitmap\delete.bmp" />
|
||||
<Image Include="res\Bitmap\update.bmp" />
|
||||
<Image Include="res\Bitmap\Shutdown.bmp" />
|
||||
<Image Include="res\Bitmap\Reboot.bmp" />
|
||||
<Image Include="res\Bitmap\Logout.bmp" />
|
||||
<Image Include="res\Bitmap\PortProxyStd.bmp" />
|
||||
<Image Include="res\Bitmap\Show.bmp" />
|
||||
<Image Include="res\Bitmap\Snapshot.bmp" />
|
||||
<Image Include="res\Bitmap\Exit.bmp" />
|
||||
<Image Include="res\Bitmap\Settings.bmp" />
|
||||
<Image Include="res\Bitmap\Wallet.bmp" />
|
||||
@@ -277,78 +320,10 @@
|
||||
<Image Include="res\Bitmap\Trigger.bmp" />
|
||||
<Image Include="res\Bitmap\WebDesktop.bmp" />
|
||||
<Image Include="res\Bitmap\PluginConfig.bmp" />
|
||||
<Image Include="res\bitmap\bitmap9.bmp" />
|
||||
<Image Include="res\Bitmap\Snapshot.bmp" />
|
||||
<Image Include="res\bitmap\compress.bmp" />
|
||||
<Image Include="res\bitmap\uncompress.bmp" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="..\..\Release\ghost.exe" />
|
||||
<None Include="..\..\Release\ServerDll.dll" />
|
||||
<None Include="..\..\Release\TestRun.exe" />
|
||||
<None Include="..\..\Release\TinyRun.dll" />
|
||||
<None Include="..\..\x64\Release\ghost.exe" />
|
||||
<None Include="..\..\x64\Release\ServerDll.dll" />
|
||||
<None Include="..\..\x64\Release\TestRun.exe" />
|
||||
<None Include="..\..\x64\Release\TinyRun.dll" />
|
||||
<None Include="res\1.cur" />
|
||||
<None Include="res\2.cur" />
|
||||
<None Include="res\2015Remote.ico" />
|
||||
<None Include="res\3.cur" />
|
||||
<None Include="res\4.cur" />
|
||||
<None Include="res\arrow.cur" />
|
||||
<None Include="res\audio.ico" />
|
||||
<None Include="res\bitmap\bmp00001.bmp" />
|
||||
<None Include="res\Bitmap\Online.bmp" />
|
||||
<None Include="res\bitmap\toolbar1.bmp" />
|
||||
<None Include="res\Bitmap\ToolBar_File.bmp" />
|
||||
<None Include="res\Bitmap\ToolBar_Main.bmp" />
|
||||
<None Include="res\cmdshell.ico" />
|
||||
<None Include="res\cursor5.cur" />
|
||||
<None Include="res\Cur\Drag.cur" />
|
||||
<None Include="res\Cur\MutiDrag.cur" />
|
||||
<None Include="res\dword.ico" />
|
||||
<None Include="res\file.ico" />
|
||||
<None Include="res\My2015Remote.rc2" />
|
||||
<None Include="res\pc.ico" />
|
||||
<None Include="res\string.ico" />
|
||||
<None Include="res\upx.exe" />
|
||||
<None Include="res\frpc.dll" />
|
||||
<None Include="..\..\Release\SCLoader.exe" />
|
||||
<None Include="..\..\x64\Release\SCLoader.exe" />
|
||||
<None Include="res\rcedit.exe" />
|
||||
<None Include="stub2\stub32.bin" />
|
||||
<None Include="stub2\stub64.bin" />
|
||||
<None Include="res\SCLoader_32.exe" />
|
||||
<None Include="res\SCLoader_64.exe" />
|
||||
<None Include="..\..\linux\ghost" />
|
||||
<None Include="res\Cur\4.cur" />
|
||||
<None Include="res\Cur\2.cur" />
|
||||
<None Include="res\Cur\3.cur" />
|
||||
<None Include="res\Cur\1.cur" />
|
||||
<None Include="res\Cur\arrow.cur" />
|
||||
<None Include="res\3rd\upx.exe" />
|
||||
<None Include="res\3rd\frpc.dll" />
|
||||
<None Include="res\3rd\frps.dll" />
|
||||
<None Include="res\3rd\rcedit.exe" />
|
||||
<None Include="res\3rd\SCLoader_32.exe" />
|
||||
<None Include="res\3rd\SCLoader_64.exe" />
|
||||
<None Include="lang\en_US.ini" />
|
||||
<None Include="lang\zh_TW.ini" />
|
||||
<None Include="res\3rd\TerminalModule_x64.dll" />
|
||||
<None Include="..\..\macos\ghost" />
|
||||
<None Include="..\web\index.html" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Text Include="..\..\ReadMe.md" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Filter Include="file">
|
||||
<UniqueIdentifier>{17217547-dc35-4a87-859c-e8559529a909}</UniqueIdentifier>
|
||||
</Filter>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="res\web\xterm.min.js" />
|
||||
<None Include="res\web\xterm.css" />
|
||||
<None Include="res\web\fit.min.js" />
|
||||
<Image Include="res\bitmap\bitmap9.bmp" />
|
||||
<Image Include="res\bitmap\uninstall.bmp" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -463,12 +463,14 @@ void CBuildDlg::OnBnClickedOk()
|
||||
break;
|
||||
case IndexServerDll:
|
||||
file = "ServerDll.dll";
|
||||
targetDir = GetInstallDirectory(m_sInstallDir.IsEmpty() ? "ServerDll" : m_sInstallDir);
|
||||
typ = CLIENT_TYPE_DLL;
|
||||
szBuffer = ReadResource(is64bit ? IDR_SERVERDLL_X64 : IDR_SERVERDLL_X86, dwFileSize,
|
||||
is64bit ? ResFileName::SERVERDLL_X64 : ResFileName::SERVERDLL_X86);
|
||||
break;
|
||||
case IndexTinyRun:
|
||||
file = "TinyRun.dll";
|
||||
targetDir = GetInstallDirectory(m_sInstallDir.IsEmpty() ? "TinyRun" : m_sInstallDir);
|
||||
typ = CLIENT_TYPE_SHELLCODE;
|
||||
szBuffer = ReadResource(is64bit ? IDR_TINYRUN_X64 : IDR_TINYRUN_X86, dwFileSize,
|
||||
is64bit ? ResFileName::TINYRUN_X64 : ResFileName::TINYRUN_X86);
|
||||
@@ -484,6 +486,7 @@ void CBuildDlg::OnBnClickedOk()
|
||||
szBuffer = ReadResource(IDR_MACOS_GHOST, dwFileSize, ResFileName::GHOST_MACOS);
|
||||
break;
|
||||
case OTHER_ITEM: {
|
||||
targetDir = GetInstallDirectory(m_sInstallDir.IsEmpty() ? "YamaDll" : m_sInstallDir);
|
||||
m_OtherItem.GetWindowTextA(file);
|
||||
typ = -1;
|
||||
if (file != _TR("未选择文件")) {
|
||||
@@ -627,15 +630,15 @@ void CBuildDlg::OnBnClickedOk()
|
||||
BOOL checked = m_BtnFileServer.GetCheck() == BST_CHECKED;
|
||||
if (checked) {
|
||||
strcpy(sc->downloadUrl, m_sDownloadUrl.IsEmpty() ? BuildPayloadUrl(m_strIP, sc->file) : m_sDownloadUrl);
|
||||
if (m_sDownloadUrl.IsEmpty()) MessageBoxL(CString("文件下载地址: \r\n") + sc->downloadUrl, "提示", MB_ICONINFORMATION);
|
||||
if (m_sDownloadUrl.IsEmpty()) MessageBoxL(_TR("文件下载地址: \r\n") + sc->downloadUrl, "提示", MB_ICONINFORMATION);
|
||||
}
|
||||
tip = payload.IsEmpty() ? "\r\n警告: 没有生成载荷!" :
|
||||
checked ? "\r\n提示: 本机提供下载时,载荷文件必须拷贝至\"Payloads\"目录。" : "\r\n提示: 载荷文件必须拷贝至程序目录。";
|
||||
tip = payload.IsEmpty() ? _TR("\r\n警告: 没有生成载荷!") :
|
||||
checked ? _TR("\r\n提示: 本机提供下载时,载荷文件必须拷贝至\"Payloads\"目录。") : _TR("\r\n提示: 载荷文件必须拷贝至程序目录。");
|
||||
}
|
||||
BOOL r = WriteBinaryToFile(strSeverFile.GetString(), (char*)data, dwSize);
|
||||
if (r) {
|
||||
r = WriteBinaryToFile(payload.GetString(), (char*)srcData, srcLen, n == Payload_Raw ? 0 : -1);
|
||||
if (!r) tip = "\r\n警告: 生成载荷失败!";
|
||||
if (!r) tip = _TR("\r\n警告: 生成载荷失败!");
|
||||
} else {
|
||||
MessageBoxL(_TR("文件生成失败: ") + "\r\n" + strSeverFile, "提示", MB_ICONINFORMATION);
|
||||
}
|
||||
@@ -647,7 +650,7 @@ void CBuildDlg::OnBnClickedOk()
|
||||
} else if (sel == CLIENT_PE_TO_SEHLLCODE) {
|
||||
int pe_2_shellcode(const std::string & in_path, const std::string & out_str);
|
||||
int ret = pe_2_shellcode(strSeverFile.GetString(), strSeverFile.GetString());
|
||||
if (ret)MessageBoxL(CString("ShellCode 转换异常, 异常代码: ") + CString(std::to_string(ret).c_str()),
|
||||
if (ret)MessageBoxL(_TR("ShellCode 转换异常, 异常代码: ") + CString(std::to_string(ret).c_str()),
|
||||
"提示", MB_ICONINFORMATION);
|
||||
} else if (sel == CLIENT_COMPRESS_SC_AES_OLD || // 兼容旧版本
|
||||
sel == CLIENT_COMP_SC_AES_OLD_UPX) {
|
||||
@@ -927,7 +930,7 @@ void CBuildDlg::OnCbnSelchangeComboExe()
|
||||
SAFE_DELETE_ARRAY(szBuffer);
|
||||
}
|
||||
} else {
|
||||
m_OtherItem.SetWindowTextA("未选择文件");
|
||||
m_OtherItem.SetWindowTextA(_TR("未选择文件"));
|
||||
}
|
||||
m_OtherItem.ShowWindow(SW_SHOW);
|
||||
} else {
|
||||
|
||||
@@ -53,8 +53,9 @@ static int ParseRemotePortFromFrpConfig(const std::string& frpConfig);
|
||||
static bool FreeFrpPortAllocation(int port, const std::string& expectedOwner);
|
||||
|
||||
// 获取所有授权信息
|
||||
std::vector<LicenseInfo> GetAllLicenses()
|
||||
std::vector<LicenseInfo> GetAllLicenses(int* activeNum)
|
||||
{
|
||||
if (activeNum) *activeNum = 0;
|
||||
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||
std::vector<LicenseInfo> licenses;
|
||||
std::string iniPath = GetLicensesPath();
|
||||
@@ -98,6 +99,7 @@ std::vector<LicenseInfo> GetAllLicenses()
|
||||
it = kv.find("Status");
|
||||
if (it != kv.end()) info.Status = it->second;
|
||||
else info.Status = LICENSE_STATUS_ACTIVE; // 默认为有效
|
||||
if (activeNum && info.Status == LICENSE_STATUS_ACTIVE) (*activeNum)++;
|
||||
|
||||
it = kv.find("PendingExpireDate");
|
||||
if (it != kv.end()) info.PendingExpireDate = it->second;
|
||||
|
||||
@@ -102,7 +102,7 @@ public:
|
||||
};
|
||||
|
||||
// 获取所有授权信息
|
||||
std::vector<LicenseInfo> GetAllLicenses();
|
||||
std::vector<LicenseInfo> GetAllLicenses(int *activeNum=0);
|
||||
|
||||
// 更新授权状态
|
||||
bool SetLicenseStatus(const std::string& deviceID, const std::string& status);
|
||||
|
||||
@@ -1714,7 +1714,20 @@ void CScreenSpyDlg::OnPaint()
|
||||
StretchBlt(m_hFullDC, 0, 0, dstW, dstH, m_hFullMemDC, 0, 0, srcW, srcH, SRCCOPY);
|
||||
}
|
||||
} else {
|
||||
BitBlt(m_hFullDC, 0, 0, srcW, srcH, m_hFullMemDC, m_ulHScrollPos, m_ulVScrollPos, SRCCOPY);
|
||||
// 实际可见的位图像素数 = 位图剩余宽高(去掉滚动偏移后)与窗口的较小值
|
||||
int visW = max(0, min(srcW - (int)m_ulHScrollPos, dstW));
|
||||
int visH = max(0, min(srcH - (int)m_ulVScrollPos, dstH));
|
||||
if (visW > 0 && visH > 0)
|
||||
BitBlt(m_hFullDC, 0, 0, visW, visH, m_hFullMemDC, m_ulHScrollPos, m_ulVScrollPos, SRCCOPY);
|
||||
// 位图未覆盖的区域(远程分辨率小于窗口 / 滚动到边缘)填黑,防止残影
|
||||
if (visW < dstW) {
|
||||
RECT rc = { visW, 0, dstW, dstH };
|
||||
FillRect(m_hFullDC, &rc, (HBRUSH)GetStockObject(BLACK_BRUSH));
|
||||
}
|
||||
if (visH < dstH) {
|
||||
RECT rc = { 0, visH, dstW, dstH };
|
||||
FillRect(m_hFullDC, &rc, (HBRUSH)GetStockObject(BLACK_BRUSH));
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制框选矩形(左键放大用红色,右键截图用绿色,二者颜色错开避免误操作)
|
||||
@@ -2177,6 +2190,7 @@ void CScreenSpyDlg::OnSysCommand(UINT nID, LPARAM lParam)
|
||||
}
|
||||
ShowScrollBar(SB_BOTH, !m_bAdaptiveSize);
|
||||
SysMenu->CheckMenuItem(IDM_ADAPTIVE_SIZE, m_bAdaptiveSize ? MF_CHECKED : MF_UNCHECKED);
|
||||
Invalidate(FALSE); // 立即重绘,清除旧模式的残留画面
|
||||
break;
|
||||
}
|
||||
case IDM_AUDIO_TOGGLE: {
|
||||
|
||||
@@ -392,6 +392,10 @@ public:
|
||||
// 仅作为标记供后续命令处理 / 未来收紧策略使用。
|
||||
std::atomic<bool> m_bAuthenticated{false};
|
||||
|
||||
// 心跳包到达 IOCP 线程的时刻(ms),用于精确计算 ProcessingMs,
|
||||
// 避免 UI 消息队列等待时间污染服务端耗时统计。
|
||||
std::atomic<uint64_t> HeartbeatRecvMs{0};
|
||||
|
||||
// 预分配的解压缩缓冲区,避免频繁内存分配
|
||||
PBYTE DecompressBuffer = nullptr;
|
||||
ULONG DecompressBufferSize = 0;
|
||||
|
||||
@@ -222,7 +222,7 @@ BOOL CSettingDlg::OnInitDialog()
|
||||
#endif
|
||||
m_nFrpPort = THIS_CFG.GetInt("frp", "server_port", 7000);
|
||||
m_sFrpToken = THIS_CFG.GetStr("frp", "token").c_str();
|
||||
m_nFileServerPort = THIS_CFG.GetInt("settings", "WebSvrPort", -1);
|
||||
m_nFileServerPort = THIS_CFG.GetInt("settings", "WebSvrPort", 8080);
|
||||
|
||||
int size = THIS_CFG.GetInt("settings", "VideoWallSize");
|
||||
m_ComboVideoWall.InsertStringL(0, "无");
|
||||
@@ -264,9 +264,6 @@ void CSettingDlg::OnBnClickedButtonSettingapply()
|
||||
THIS_CFG.SetInt("frp", "server_port", m_nFrpPort);
|
||||
THIS_CFG.SetStr("frp", "token", m_sFrpToken.GetString());
|
||||
THIS_CFG.SetInt("settings", "WebSvrPort", m_nFileServerPort);
|
||||
if (m_nFileServerPort > 0 && THIS_CFG.GetStr("settings", "Authorization").empty()) {
|
||||
MessageBoxL("Web端口设置无效!\n必须具有有效的授权才能使用Web远程监控!", "提示", MB_ICONWARNING);
|
||||
}
|
||||
|
||||
THIS_CFG.SetInt("settings", "VideoWallSize", m_ComboVideoWall.GetCurSel()+1);
|
||||
|
||||
|
||||
@@ -271,13 +271,13 @@
|
||||
#define BRAND_URL_FEEDBACK "https://t.me/SimpleRemoter"
|
||||
|
||||
// 帮助文档链接(帮助菜单 → 什么是这个)
|
||||
#define BRAND_URL_WIKI "https://git.simpleremoter.com/"
|
||||
#define BRAND_URL_WIKI "https://simpleremoter.com/docs"
|
||||
|
||||
// 请求授权链接(工具菜单 → 请求授权)
|
||||
#define BRAND_URL_REQUEST_AUTH "https://simpleremoter.com/"
|
||||
#define BRAND_URL_REQUEST_AUTH "https://simpleremoter.com/login"
|
||||
|
||||
// 获取插件
|
||||
#define BRAND_URL_GET_PLUGIN "This feature has not been implemented!\nPlease contact: 962914132@qq.com"
|
||||
#define BRAND_URL_GET_PLUGIN "https://simpleremoter.com/plugins"
|
||||
|
||||
// ============================================================
|
||||
// 内部使用 - 请勿修改以下内容
|
||||
|
||||
@@ -1600,6 +1600,9 @@ std::string CWebService::BuildDeviceListJson(const std::string& username) {
|
||||
device["screen"] = AnsiToUtf8(resolution); // e.g. "2:3840x1080"
|
||||
}
|
||||
|
||||
CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE);
|
||||
device["clientType"] = AnsiToUtf8(clientType); // e.g. "MAC", "LNX", "EXE"
|
||||
|
||||
res["devices"].append(device);
|
||||
}
|
||||
LeaveCriticalSection(&m_pParentDlg->m_cs);
|
||||
|
||||
@@ -1746,7 +1746,9 @@ Ghostִ
|
||||
代理=Proxy
|
||||
勾选: 对下级隐藏 灰色: 上级已禁用=Checked: Hide from subordinates Gray: Disabled by upper level
|
||||
删除选中=Delete Selected
|
||||
复制选中=Copy Selected
|
||||
清空日志=Clear Log
|
||||
隐藏日志=Hide Message
|
||||
FRPS 运行在本机=FRPS runs on localhost
|
||||
内网地址:=LAN Address:
|
||||
该地址必须为FRP代理服务器IP=Address must be FRP proxy server IP
|
||||
@@ -1925,3 +1927,5 @@ FRPC Զ
|
||||
压缩(&C)=&Compress
|
||||
解压缩(&U)=&Uncompress
|
||||
\n默认密码是: admin=\nDefault password is: admin
|
||||
卸载软件=Uninstall Software
|
||||
是否移除此软件?=Uninstall this software. Are you sure?
|
||||
|
||||
@@ -1739,7 +1739,9 @@ Ghostִ
|
||||
代理=代理
|
||||
勾选: 对下级隐藏 灰色: 上级已禁用=勾選: 對下級隱藏 灰色: 上級已禁用
|
||||
删除选中=刪除選中
|
||||
复制选中=复制選中
|
||||
清空日志=清空日誌
|
||||
隐藏日志=隐藏日誌
|
||||
FRPS 运行在本机=FRPS 运行在本机
|
||||
内网地址:=内網地址:
|
||||
该地址必须为FRP代理服务器IP=該地址必須為FRP代理服務器IP
|
||||
@@ -1916,3 +1918,5 @@ FRPC Զ
|
||||
压缩(&C)=壓縮(&C)
|
||||
解压缩(&U)=解壓縮(&U)
|
||||
\n默认密码是: admin=\n默认密码是: admin
|
||||
卸载软件=卸载软件
|
||||
是否移除此软件?=是否移除此软件?
|
||||
|
||||
BIN
server/2015Remote/res/Bitmap/uninstall.bmp
Normal file
BIN
server/2015Remote/res/Bitmap/uninstall.bmp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 822 B |
@@ -264,8 +264,8 @@
|
||||
#define IDR_WEB_XTERM_FIT_JS 384
|
||||
#define IDR_WEB_INDEX_HTML 385
|
||||
#define IDB_BITMAP_COMPRESS 386
|
||||
#define IDB_BITMAP9 387
|
||||
#define IDB_BITMAP_UNCOMPRESS 387
|
||||
#define IDB_BITMAP9 388
|
||||
#define IDC_MESSAGE 1000
|
||||
#define IDC_ONLINE 1001
|
||||
#define IDC_STATIC_TIPS 1002
|
||||
@@ -977,7 +977,7 @@
|
||||
#define ID_33040 33040
|
||||
#define ID_MSGLOG_CLEAR 33041
|
||||
#define ID_CANCEL_SHARE 33042
|
||||
#define ID_33043 33043
|
||||
#define ID_MSGLOG_COPY 33043
|
||||
#define ID_WEB_REMOTE_CONTROL 33044
|
||||
#define ID_TOOL_PLUGIN_SETTINGS 33045
|
||||
#define ID_33046 33046
|
||||
@@ -993,14 +993,18 @@
|
||||
#define ID_MENU_COMPRESS 33055
|
||||
#define ID_33056 33056
|
||||
#define ID_MENU_UNCOMPRESS 33057
|
||||
#define ID_33058 33058
|
||||
#define ID_UNINSTALL_SOFTWARE 33059
|
||||
#define ID_33060 33060
|
||||
#define ID_VIEW_HIDE_LOG 33061
|
||||
#define ID_EXIT_FULLSCREEN 40001
|
||||
|
||||
// Next default values for new objects
|
||||
//
|
||||
#ifdef APSTUDIO_INVOKED
|
||||
#ifndef APSTUDIO_READONLY_SYMBOLS
|
||||
#define _APS_NEXT_RESOURCE_VALUE 388
|
||||
#define _APS_NEXT_COMMAND_VALUE 33058
|
||||
#define _APS_NEXT_RESOURCE_VALUE 389
|
||||
#define _APS_NEXT_COMMAND_VALUE 33062
|
||||
#define _APS_NEXT_CONTROL_VALUE 2542
|
||||
#define _APS_NEXT_SYMED_VALUE 105
|
||||
#endif
|
||||
|
||||
@@ -105,6 +105,10 @@
|
||||
#define WM_PREVIEW_LOOP_CLOSED WM_USER+3035
|
||||
#define WM_TRIAL_RTT_ABUSE WM_USER+3036 // 试用版 RTT 反代理:服务端检测到滥用,通知主窗口弹框
|
||||
#define WM_TRIAL_WAN_IP_ABUSE WM_USER+3037 // 试用版 IP 段检测:OnAccept 发现入站为公网 IP,通知主窗口弹框
|
||||
#define WM_ACTIVE_LICENSE_NUM WM_USER+3038
|
||||
#define WM_ONLINE_HOSTNUM WM_USER+3039
|
||||
#define WM_SPLITTER_MOVED WM_USER+3040
|
||||
#define WM_SPLITTER_RELEASED WM_USER+3041
|
||||
|
||||
#ifdef _UNICODE
|
||||
#if defined _M_IX86
|
||||
|
||||
6
server/go/.vscode/launch.json
vendored
6
server/go/.vscode/launch.json
vendored
@@ -9,7 +9,8 @@
|
||||
"program": "${workspaceFolder}/cmd",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"args": [
|
||||
"-port=9090"
|
||||
"-port=6543",
|
||||
"--http-port=8080"
|
||||
],
|
||||
"env": {
|
||||
"YAMA_WEB_ADMIN_PASS": "3.14159"
|
||||
@@ -25,7 +26,8 @@
|
||||
"program": "${workspaceFolder}/cmd",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"args": [
|
||||
"-port=9090"
|
||||
"-port=6543",
|
||||
"--http-port=8080"
|
||||
],
|
||||
"env": {
|
||||
"YAMA_WEB_ADMIN_PASS": "3.14159"
|
||||
|
||||
@@ -159,11 +159,14 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
|
||||
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` |
|
||||
| `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_password` |
|
||||
| `YAMA_SIGN_PASSWORD` | **[LocalSigner 模式]** HMAC-SHA256 master key,直接给 CMD_MASTERSETTING 签名。Operator 自己的部署用。设置此变量后进入 LocalSigner 模式(见下方"签名模式")。 | `<deployment-shared-secret>` |
|
||||
| `YAMA_LICENSE_SERVER` | **[RemoteSigner 模式]** Operator 的 License Server 公开 URL。客户部署设置此变量后进入 RemoteSigner 模式 —— 每次新设备登录会 HTTPS POST 给 License Server 拿签名,本机永远看不到 HMAC master key。必须与 `YAMA_LICENSE_TOKEN` 同时设置。 | `https://license.example.com` |
|
||||
| `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWT(RS256),作为 Bearer token 鉴权。每个客户一份。 | `eyJhbGciOiJSUzI1NiI...` |
|
||||
| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` |
|
||||
| `YAMA_LICENSE_SERVER` | **[RemoteSigner / Trial 模式]** License Server 公开 URL。**不设置则用 DefaultLicenseServerURL (`https://web.just-do-it.icu:8080`)**。客户部署设置此变量后进入 RemoteSigner 模式 —— 每次新设备登录会 HTTPS POST 给 License Server 拿签名,本机永远看不到 HMAC master key。 | `https://license.example.com` |
|
||||
| `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWT(RS256),作为 Bearer token 鉴权。每个客户一份。**未设置则进入 TRIAL 模式(匿名试用,按出口 IP 配额 2 台)**。 | `eyJhbGciOiJSUzI1NiI...` |
|
||||
| `YAMA_LICENSE_DISABLED` | 设为 `1` 强制 NoOp 模式(既不读 token 也不连 License Server,客户端会拒绝屏幕/文件功能)。给本地开发 / 离线测试用。 | `1` |
|
||||
| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner / Trial 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` |
|
||||
| `YAMA_LICENSE_PRIVATE_KEY` | **[issue-token 子命令]** RSA 私钥 PEM 路径,用于离线签发客户 JWT。与 `YAMA_LICENSE_PUBLIC_KEY` 配对。设置后 `issue-token` 子命令无需 `-key` 参数。 | `/opt/yama/license_priv.pem` |
|
||||
| `YAMA_LICENSE_PUBLIC_KEY` | **[License Server 模式]** Operator 自己(已经是 LocalSigner)想顺便对外提供 License Server 时,用来验证客户提交的 JWT 的 RSA 公钥 PEM 路径。必须与 `YAMA_LICENSE_HTTP_ADDR` 同时设置。 | `./license_pub.pem` |
|
||||
| `YAMA_LICENSE_HTTP_ADDR` | **[License Server 模式]** License Server HTTP 监听地址。**仅在 LocalSigner 模式下生效**(RemoteSigner 客户不能反向当 license server)。建议挂 nginx/Caddy 加 TLS 后对外。 | `:8443` |
|
||||
| `YAMA_LICENSE_STATE_PATH` | **[License Server 模式]** Quota 状态持久化文件路径。设置后每次新设备入队或 slot 被驱逐时原子写入磁盘(tmp + rename),License Server 重启时从此文件恢复设备列表,消除重启期间因 tracker 清空导致的配额绕过窗口。不设则仅内存状态,重启后 tracker 清零。 | `/var/lib/yama/quota.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_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` |
|
||||
@@ -182,15 +185,16 @@ $env:YAMA_PWD="your_super_password"
|
||||
|
||||
## 签名模式(CMD_MASTERSETTING signer)
|
||||
|
||||
单个 Go 二进制按启动时的环境变量自动选择三种签名模式之一。同一个 master HMAC key 永远不会出现在客户机器上 —— 这是把 Go server 商业化部署给付费客户的核心安全前提。
|
||||
单个 Go 二进制按启动时的环境变量自动选择四种签名模式之一。同一个 master HMAC key 永远不会出现在客户机器上 —— 这是把 Go server 商业化部署给付费客户的核心安全前提。
|
||||
|
||||
| 模式 | 触发条件 | 用途 |
|
||||
| ---- | -------- | ---- |
|
||||
| **LocalSigner** | `YAMA_SIGN_PASSWORD` 已设 | Operator 自己的部署。master HMAC key 在本机内存,签名直连 HMAC,微秒级延迟。**可选**:再设 `YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR` 让本进程同时对外提供 License Server HTTP 服务。 |
|
||||
| **RemoteSigner** | `YAMA_LICENSE_SERVER` + `YAMA_LICENSE_TOKEN` 已设 | 客户部署。本机**永远看不到** master HMAC key —— 每次新设备登录会 HTTPS POST 到 operator 的 License Server,拿到签名后塞进 CMD_MASTERSETTING。同 (clientID, startTime) 元组的签名缓存 24h(可调,`YAMA_LICENSE_OFFLINE_HRS`),用于扛短暂网络故障。 |
|
||||
| **NoOpSigner** | 上述都没设 | Free tier。返回空签名 → 客户端私有库拒绝启动 screen/file 功能。设备列表仍然可用。 |
|
||||
| **RemoteSigner (paid)** | `YAMA_LICENSE_TOKEN` 已设(`YAMA_LICENSE_SERVER` 可选,未设则用 `DefaultLicenseServerURL`) | 付费客户部署。本机**永远看不到** master HMAC key —— 每次新设备登录会 HTTPS POST 到 operator 的 License Server,带 Bearer JWT 鉴权,拿到签名后塞进 CMD_MASTERSETTING。同 (clientID, startTime) 元组的签名缓存 24h(可调,`YAMA_LICENSE_OFFLINE_HRS`),用于扛短暂网络故障。 |
|
||||
| **RemoteSigner (trial)** | `YAMA_LICENSE_TOKEN` 未设、且未显式 `YAMA_LICENSE_DISABLED=1` | 匿名试用模式。没有 JWT,连默认 License Server URL;服务端按下级出口 IP 识别身份,配额 `FreeMaxDevices` (2 台),且匿名 `/license/sign` 受 IP 限流(10 req/min)。零配置直接跑就在这个模式。 |
|
||||
| **NoOpSigner** | `YAMA_LICENSE_DISABLED=1` | 显式离线/开发模式。不连任何 License Server,返回空签名 → 客户端私有库拒绝启动 screen/file 功能。设备列表仍然可用。 |
|
||||
|
||||
注:`YAMA_SIGN_PASSWORD` 与 `YAMA_LICENSE_SERVER` 同时设置时 LocalSigner 优先(operator 自己的 server 不应该回连自己)。
|
||||
注:`YAMA_SIGN_PASSWORD` 与 `YAMA_LICENSE_*` 同时设置时 LocalSigner 优先(operator 自己的 server 不应该回连自己)。
|
||||
|
||||
### License Server endpoints(仅 LocalSigner 暴露)
|
||||
|
||||
@@ -203,22 +207,41 @@ $env:YAMA_PWD="your_super_password"
|
||||
|
||||
### 颁发客户 JWT
|
||||
|
||||
**第一步:一次性生成 RSA 密钥对**(只在授权中心执行一次,私钥永久保管)
|
||||
|
||||
```bash
|
||||
# 一次性生成 RSA 密钥对(私钥 operator 自己保管,公钥用于 License Server 验证)
|
||||
openssl genrsa -out license_priv.pem 2048
|
||||
openssl rsa -in license_priv.pem -pubout -out license_pub.pem
|
||||
```
|
||||
|
||||
底层 API 是 `licensing.Issue(privKey, sub, tier, maxDevices, ttl)`(见 [`licensing/server.go`](licensing/server.go))。一个开箱即用的 CLI 包装在独立仓库 [`yama-issue-token`](https://git.simpleremoter.com/yuanyuanxiang/yama-issue-token)(go.mod `replace` 指向本仓库的 `licensing` 包),用法:
|
||||
- `license_priv.pem` — 私钥,设为 `YAMA_LICENSE_PRIVATE_KEY`,仅存于授权中心,**绝不外发**
|
||||
- `license_pub.pem` — 公钥,设为 `YAMA_LICENSE_PUBLIC_KEY`,授权中心 License Server 用于验证客户 JWT
|
||||
|
||||
**第二步:颁发 JWT**
|
||||
|
||||
`server` 二进制内置 `issue-token` 子命令。授权中心已配置 `YAMA_LICENSE_PRIVATE_KEY` 时,只需要提供客户标识:
|
||||
|
||||
```bash
|
||||
yama-issue-token -priv license_priv.pem -sub acme-corp -tier paid -max 100 -days 365
|
||||
# 最简调用(私钥路径从 $YAMA_LICENSE_PRIVATE_KEY 读取)
|
||||
server issue-token -sub customer-acme
|
||||
|
||||
# 完整参数
|
||||
server issue-token \
|
||||
-sub customer-acme \ # 客户唯一标识(必填)
|
||||
-tier paid \ # paid 或 trial(默认 paid)
|
||||
-devices 20 \ # 最大并发设备数(默认 10)
|
||||
-ttl 8760h # 有效期(默认 8760h = 1 年)
|
||||
```
|
||||
|
||||
| Tier | max_devices 默认 | 备注 |
|
||||
| ---- | ---------------- | ---- |
|
||||
| `trial` | 20(JWT 未指定时) | 移植 C++ 反代理 RTT 逻辑 |
|
||||
| `paid` | JWT 必须显式指定 | 长 TTL token |
|
||||
命令将 JWT 字符串输出到 stdout,将其作为 `YAMA_LICENSE_TOKEN` 交给客户。
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
| ---- | ------ | ---- |
|
||||
| `-key` | `$YAMA_LICENSE_PRIVATE_KEY` | RSA 私钥 PEM 路径;env 已设则无需重复指定 |
|
||||
| `-sub` | (必填) | 客户唯一标识,建议用 `company-id` 格式 |
|
||||
| `-tier` | `paid` | `paid` 或 `trial` |
|
||||
| `-devices` | `10` | 并发设备上限;`paid` 必须显式设置合理值 |
|
||||
| `-ttl` | `8760h` | Token 有效期,支持 Go duration 语法(`h`/`m`/`s`) |
|
||||
|
||||
## 使用示例
|
||||
|
||||
@@ -496,6 +519,85 @@ publicIP := info.GetReservedField(11) // 公网 IP
|
||||
- [gopkg.in/natefinch/lumberjack.v2](https://github.com/natefinch/lumberjack) - 日志轮转
|
||||
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) - GBK 编码转换
|
||||
|
||||
## 配置和启动参数
|
||||
|
||||
### 启动参数说明
|
||||
|
||||
- `-p` / `--port`:受管设备 TCP 监听端口(默认 6543,可分号分隔多端口,如 `6543;6544;6545`)
|
||||
- `--http-port`:Web 管理 HTTP 端口(默认 8080,设为 0 则禁用 Web 管理)
|
||||
- `--no-console`:守护进程模式,不输出到控制台(日志仍写入 `logs/server.log`)
|
||||
|
||||
### (1)授权中心(由我运行)
|
||||
|
||||
**模式判定**:设置了 `YAMA_SIGN_PASSWORD`,本进程以 LocalSigner 持有主 HMAC 密钥;可选地开启
|
||||
License Server HTTP(`YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR`),向下级服务签发带 24h 缓存的授权。
|
||||
|
||||
```bash
|
||||
export YAMA_PWD="授权码 HMAC 校验密钥(TCP 端 passcode 签名验证)"
|
||||
export YAMA_WEB_ADMIN_PASS="Web 端登录密码"
|
||||
export YAMA_SIGN_PASSWORD="主控签名 HMAC 主密钥(受管设备验证服务端身份)"
|
||||
export YAMA_WEB_TRUST_PROXY=1
|
||||
export YAMA_WEB_ALLOWED_ORIGINS="https://web.just-do-it.icu:8080"
|
||||
export YAMA_LICENSE_PRIVATE_KEY="/opt/yama/license_priv.pem"
|
||||
export YAMA_LICENSE_PUBLIC_KEY="/opt/yama/license_pub.pem"
|
||||
export YAMA_LICENSE_HTTP_ADDR="127.0.0.1:8443"
|
||||
|
||||
nohup ./server_linux_amd64 -p 8000 --http-port=9001 --no-console &
|
||||
```
|
||||
|
||||
由前端代理将公网流量(受管设备 8000、Web 9001、License Server 8443)转发到本进程。受管设备通过 8000 端口直连服务端;
|
||||
下级服务(运行 RemoteSigner)通过代理对外暴露的 `YAMA_LICENSE_SERVER` 公网 URL 取授权签名,代理后端转发到本进程
|
||||
绑定的 `YAMA_LICENSE_HTTP_ADDR (127.0.0.1:8443)`。
|
||||
|
||||
可选环境变量:
|
||||
|
||||
- `YAMA_PWDHASH`:TCP 授权码的 SHA256 哈希(未设置则使用代码内置默认值,启动 banner 会有警告)
|
||||
- `YAMA_USERS_FILE`:额外 Web 用户列表 JSON 路径(默认 `users.json`)
|
||||
|
||||
### (2)下级服务(运营商部署)
|
||||
|
||||
下级二进制把"小白用户开箱即用"作为目标——**理想情况下,什么都不配,直接 `./server_linux_amd64` 就能跑起来**。
|
||||
启动后默认行为:
|
||||
|
||||
- License Server:默认连到 `https://web.just-do-it.icu:8080`(DefaultLicenseServerURL)
|
||||
- 模式:无 `YAMA_LICENSE_TOKEN` → 进入**试用模式**(TRIAL),授权中心按下级出口 IP 识别身份,最多 **2 台**
|
||||
受管设备(FreeMaxDevices)
|
||||
- Web 管理:无 `YAMA_WEB_ADMIN_PASS` → 使用默认账号 `admin/admin`,启动日志会大字警告
|
||||
- 监听端口:受管设备 TCP 6543、Web 8080
|
||||
|
||||
#### 最简启动(零配置试用,2 台设备上限)
|
||||
|
||||
```bash
|
||||
nohup ./server_linux_amd64 --no-console &
|
||||
```
|
||||
|
||||
启动日志会显示:
|
||||
|
||||
```
|
||||
WARN ⚠ YAMA_WEB_ADMIN_PASS / YAMA_PWD 均未设置,Web 管理使用默认密码 admin/admin — 生产环境务必覆盖
|
||||
INFO Signer mode: TRIAL (anonymous试用模式, license server=https://web.just-do-it.icu:8080,
|
||||
最多 2 台受管设备; 设置 YAMA_LICENSE_TOKEN 解锁付费配额)
|
||||
```
|
||||
|
||||
#### 推荐生产配置(覆盖默认密码 + 付费 token)
|
||||
|
||||
```bash
|
||||
export YAMA_WEB_ADMIN_PASS="自定义 Web 登录密码"
|
||||
export YAMA_LICENSE_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3M..." # 向授权中心申请
|
||||
|
||||
nohup ./server_linux_amd64 -p 6543 --http-port=8080 --no-console &
|
||||
```
|
||||
|
||||
设置 `YAMA_LICENSE_TOKEN` 后切换为 REMOTE 模式(付费),配额由 JWT 的 `max_devices` 决定。
|
||||
如果同时改 `YAMA_LICENSE_SERVER` 可以接到自部署的授权中心,否则继续走默认 URL。
|
||||
|
||||
#### 其他可选
|
||||
|
||||
- `YAMA_LICENSE_DISABLED=1`:完全禁用 License Server 通信(离线 / 内网测试),客户端会拒绝屏幕/文件功能但设备列表仍能用
|
||||
- `YAMA_LICENSE_OFFLINE_HRS=24`:本地签名缓存的 TTL,默认 24h
|
||||
- 受管设备的 `client.exe` 由 Windows 主控端生成(已绑定服务端地址和公钥),下级运营商把它分发给终端用户。
|
||||
client 通过 6543 端口连接到本服务端;用户通过 8080 端口登录 Web 管理页面。
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
@@ -415,9 +415,29 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
||||
resolution = info.GetReservedField(protocol.ResFieldResolution)
|
||||
}
|
||||
|
||||
// Register with hub so the web side can list this device. Sub-connections
|
||||
// (screen / terminal etc.) reuse the MasterID and will overwrite this entry
|
||||
// harmlessly, but only the main login carries enough info to be useful here.
|
||||
// Sign BEFORE registering in the hub so a quota-rejected device never
|
||||
// appears in the web device list, even briefly. If signing fails we still
|
||||
// send CMD_MASTERSETTING (zeroed signature, wire protocol stays clean) and
|
||||
// then close the connection — no hub registration happens at all.
|
||||
sigErr := h.sendMasterSetting(ctx, info.StartTime, clientID)
|
||||
if sigErr != nil {
|
||||
// Any sign error means no valid signature was issued — close without
|
||||
// registering. Covers: quota exceeded (403), anonymous IP rate limit
|
||||
// (429), and transient errors on a brand-new device with no cache.
|
||||
// Existing devices reconnecting hit the fresh-cache path in Sign() and
|
||||
// return ("sig", nil), so they are never rejected here.
|
||||
h.log.Warn("sign failed for clientID=%s (%v) — closing connection", clientID, sigErr)
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond) // let CMD_MASTERSETTING flush
|
||||
ctx.Close()
|
||||
}()
|
||||
return
|
||||
}
|
||||
|
||||
// Signing succeeded: register with hub so the web side can list this
|
||||
// device. Sub-connections (screen / terminal etc.) reuse the MasterID and
|
||||
// will overwrite this entry harmlessly, but only the main login carries
|
||||
// enough info to be useful here.
|
||||
h.hub.Register(&hub.Device{
|
||||
ID: clientID,
|
||||
Name: name,
|
||||
@@ -434,11 +454,6 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
||||
PublicIP: clientInfo.IP,
|
||||
ConnectedAt: time.Now(),
|
||||
}, ctx)
|
||||
|
||||
// Push CMD_MASTERSETTING with a signature over "StartTime|ClientID".
|
||||
// The client's private FileUpload init verifies this before allowing
|
||||
// screen / file operations — without it the binary aborts itself.
|
||||
h.sendMasterSetting(ctx, info.StartTime, clientID)
|
||||
}
|
||||
|
||||
// sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it
|
||||
@@ -448,10 +463,10 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
||||
// - RemoteSigner: HTTPS POST to operator's License Server (customer deployment)
|
||||
// - NoOpSigner: returns empty signature (free tier; client refuses screen/file ops)
|
||||
//
|
||||
// On signer error (License Server unreachable + no cache hit), we still ship
|
||||
// a zeroed signature so the packet is well-formed; the client will retry on
|
||||
// next reconnect.
|
||||
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) {
|
||||
// Returns the signer error (not the send error) so callers can distinguish
|
||||
// quota-exceeded rejections from transient failures and act accordingly.
|
||||
// The packet is always sent — even on error — so the wire protocol stays clean.
|
||||
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) error {
|
||||
buf := make([]byte, 1+protocol.MasterSettingsSize)
|
||||
buf[0] = protocol.CmdMasterSetting
|
||||
|
||||
@@ -462,10 +477,10 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
|
||||
buf[1:5],
|
||||
uint32(protocol.DefaultReportIntervalSec))
|
||||
|
||||
sig, err := h.signer.Sign(startTime, clientID)
|
||||
if err != nil {
|
||||
sig, sigErr := h.signer.Sign(startTime, clientID)
|
||||
if sigErr != nil {
|
||||
h.log.Error("signer (%s) failed for clientID=%s: %v — sending zeroed signature",
|
||||
h.signer.Mode(), clientID, err)
|
||||
h.signer.Mode(), clientID, sigErr)
|
||||
} else if sig == "" {
|
||||
// NoOpSigner path, or LocalSigner with empty master key — same effect.
|
||||
// Log only once per process via the startup banner; don't spam here.
|
||||
@@ -477,6 +492,7 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
|
||||
if err := h.srv.Send(ctx, buf); err != nil {
|
||||
h.log.Error("CMD_MASTERSETTING send failed for conn=%d: %v", ctx.ID, err)
|
||||
}
|
||||
return sigErr
|
||||
}
|
||||
|
||||
// handleAuth handles authorization request (TOKEN_AUTH = 100)
|
||||
@@ -645,7 +661,72 @@ func splitCSV(s string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// runIssueToken handles the "issue-token" subcommand. It mints a customer JWT
|
||||
// signed with the operator's RSA private key and prints it to stdout.
|
||||
//
|
||||
// The private key path defaults to $YAMA_LICENSE_PRIVATE_KEY so that on the
|
||||
// authorization server — where env vars are already configured — only the
|
||||
// per-customer fields need to be specified:
|
||||
//
|
||||
// server issue-token -sub customer-acme [-tier paid|trial] [-devices 10] [-ttl 8760h]
|
||||
//
|
||||
// If neither -key nor $YAMA_LICENSE_PRIVATE_KEY is set, the command exits
|
||||
// with a clear error rather than silently using a wrong default.
|
||||
func runIssueToken(args []string) {
|
||||
fs := flag.NewFlagSet("issue-token", flag.ExitOnError)
|
||||
// Default from env so the operator doesn't need to retype the path.
|
||||
keyPath := fs.String("key", os.Getenv(licensing.EnvLicensePrivKeyPath),
|
||||
"Path to RSA private key PEM (PKCS#1 or PKCS#8); default: $"+licensing.EnvLicensePrivKeyPath)
|
||||
sub := fs.String("sub", "", "Customer identifier — unique string, e.g. \"customer-acme\" (required)")
|
||||
tier := fs.String("tier", licensing.TierPaid, "License tier: \"paid\" or \"trial\"")
|
||||
devices := fs.Int("devices", 10, "Max concurrent managed devices")
|
||||
ttl := fs.Duration("ttl", 365*24*time.Hour, "Token validity period, e.g. 8760h (1 year)")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s issue-token [flags]\n\nFlags:\n", os.Args[0])
|
||||
fs.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\nExample:\n %s issue-token -sub customer-acme -tier paid -devices 20 -ttl 8760h\n", os.Args[0])
|
||||
}
|
||||
_ = fs.Parse(args)
|
||||
|
||||
var errs []string
|
||||
if *keyPath == "" {
|
||||
errs = append(errs, fmt.Sprintf("-key or $%s is required", licensing.EnvLicensePrivKeyPath))
|
||||
}
|
||||
if *sub == "" {
|
||||
errs = append(errs, "-sub is required")
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
for _, e := range errs {
|
||||
fmt.Fprintln(os.Stderr, "error:", e)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fs.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
privKey, err := licensing.LoadRSAPrivateKey(*keyPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error loading private key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
token, err := licensing.Issue(privKey, *sub, *tier, *devices, *ttl)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error issuing token: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println(token)
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Subcommand dispatch: "server issue-token ..." runs the token-minting
|
||||
// helper and exits without starting the server.
|
||||
if len(os.Args) > 1 && os.Args[1] == "issue-token" {
|
||||
runIssueToken(os.Args[2:])
|
||||
return
|
||||
}
|
||||
|
||||
// Parse command line flags
|
||||
portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)")
|
||||
flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)")
|
||||
@@ -671,6 +752,7 @@ func main() {
|
||||
logCfg.Compress = true
|
||||
|
||||
log := logger.New(logCfg)
|
||||
log.Info("====== Copyright (c) 2026 simpleremoter.com. All rights resvered. ======")
|
||||
|
||||
// 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
|
||||
@@ -700,13 +782,13 @@ func main() {
|
||||
deviceHub := hub.New()
|
||||
|
||||
// Build the CMD_MASTERSETTING signer based on env vars:
|
||||
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
|
||||
// HMAC master key lives here)
|
||||
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
|
||||
// (customer deployment; never sees the master key, fetches signatures
|
||||
// from operator's License Server with 24h cache)
|
||||
// - neither → NoOpSigner (free tier; client refuses screen/file ops
|
||||
// but device list still works)
|
||||
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
|
||||
// HMAC master key lives here)
|
||||
// - YAMA_LICENSE_DISABLED=1 → NoOpSigner (explicit opt-out; dev / offline)
|
||||
// - YAMA_LICENSE_TOKEN set → RemoteSigner (paid customer; talks to
|
||||
// operator's License Server with JWT)
|
||||
// - neither of the above → RemoteSigner (anonymous trial; default
|
||||
// URL, no JWT, cap FreeMaxDevices)
|
||||
signer, mode, err := licensing.NewFromEnv(log)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize signer: %v", err)
|
||||
@@ -718,10 +800,21 @@ func main() {
|
||||
case licensing.ModeLocal:
|
||||
log.Info("Signer mode: LOCAL (operator deployment, master key held in-process)")
|
||||
case licensing.ModeRemote:
|
||||
log.Info("Signer mode: REMOTE (customer deployment, %s=%s)",
|
||||
licensing.EnvLicenseServer, os.Getenv(licensing.EnvLicenseServer))
|
||||
licServer := os.Getenv(licensing.EnvLicenseServer)
|
||||
if licServer == "" {
|
||||
licServer = licensing.DefaultLicenseServerURL
|
||||
}
|
||||
log.Info("Signer mode: REMOTE (paid customer, license server=%s)", licServer)
|
||||
case licensing.ModeTrial:
|
||||
licServer := os.Getenv(licensing.EnvLicenseServer)
|
||||
if licServer == "" {
|
||||
licServer = licensing.DefaultLicenseServerURL
|
||||
}
|
||||
log.Info("Signer mode: TRIAL (anonymous试用模式, license server=%s, 最多 %d 台受管设备; 设置 %s 解锁付费配额)",
|
||||
licServer, licensing.FreeMaxDevices, licensing.EnvLicenseToken)
|
||||
case licensing.ModeNoOp:
|
||||
log.Warn("Signer mode: NOOP (no licensing configured; screen/file features disabled on clients)")
|
||||
log.Warn("Signer mode: NOOP (licensing disabled via %s=1; client refuses screen/file features)",
|
||||
licensing.EnvLicenseDisabled)
|
||||
}
|
||||
|
||||
// If the operator also wants this LocalSigner deployment to serve as
|
||||
@@ -749,6 +842,10 @@ func main() {
|
||||
adminPass = defaultWebAdminPass
|
||||
rememberDefault("YAMA_WEB_ADMIN_PASS", defaultWebAdminPass)
|
||||
usingDefaultWebPass = true
|
||||
// Loud warn (in addition to the startup banner): the binary is now
|
||||
// accepting admin/admin on the public web UI. Anyone running this
|
||||
// without overriding the env var in prod needs to see it in red.
|
||||
log.Warn("⚠ YAMA_WEB_ADMIN_PASS / YAMA_PWD 均未设置,Web 管理使用默认密码 admin/admin — 生产环境务必覆盖")
|
||||
}
|
||||
webAuth.AddAdminFromPlainPassword("admin", adminPass)
|
||||
log.Info("Web admin user configured")
|
||||
@@ -865,33 +962,30 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
fmt.Printf("Server started on port(s): %v\n", ports)
|
||||
log.Info("Server started on port(s): %v", ports)
|
||||
if *httpPort != 0 {
|
||||
fmt.Printf("Web UI on http://localhost:%d/\n", *httpPort)
|
||||
log.Info("Web UI on http://localhost:%d/", *httpPort)
|
||||
if usingDefaultWebPass {
|
||||
fmt.Printf(" Default login: admin / %s (set YAMA_WEB_ADMIN_PASS to override)\n",
|
||||
defaultWebAdminPass)
|
||||
log.Info("Default login: admin / %s (set YAMA_WEB_ADMIN_PASS to override)", defaultWebAdminPass)
|
||||
}
|
||||
}
|
||||
if licenseHTTP != nil {
|
||||
fmt.Printf("License Server on http://%s/license/{sign,heartbeat}\n", licAddr)
|
||||
log.Info("License Server on http://%s/license/{sign,heartbeat}", licAddr)
|
||||
}
|
||||
if len(defaultsUsed) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println("[!] Using built-in defaults (set the env var to override):")
|
||||
for _, d := range defaultsUsed {
|
||||
fmt.Printf(" %s = %s\n", d.name, d.value)
|
||||
log.Info("[!] Using built-in defaults (set the env var to override): %s = %s", d.name, d.value)
|
||||
}
|
||||
}
|
||||
fmt.Println("Logs are written to: logs/server.log")
|
||||
fmt.Println("Press Ctrl+C to stop...")
|
||||
log.Info("Logs are written to: logs/server.log")
|
||||
log.Info("Press Ctrl+C to stop...")
|
||||
|
||||
// Wait for interrupt signal
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
fmt.Println("\nShutting down...")
|
||||
log.Info("\nShutting down...")
|
||||
// Order matters: drain License Server HTTP first so no handleSign is
|
||||
// mid-flight; THEN close the signer (which may release HTTP keepalives
|
||||
// in RemoteSigner mode, or be a no-op for LocalSigner/NoOp).
|
||||
@@ -907,5 +1001,5 @@ func main() {
|
||||
for _, srv := range servers {
|
||||
srv.Stop()
|
||||
}
|
||||
fmt.Println("Server stopped")
|
||||
log.Info("Server stopped")
|
||||
}
|
||||
|
||||
@@ -13,11 +13,20 @@ const (
|
||||
EnvSignPassword = "YAMA_SIGN_PASSWORD" // LocalSigner master HMAC key
|
||||
EnvLicenseServer = "YAMA_LICENSE_SERVER" // RemoteSigner: License Server base URL
|
||||
EnvLicenseToken = "YAMA_LICENSE_TOKEN" // RemoteSigner: customer JWT
|
||||
EnvLicensePrivKeyPath = "YAMA_LICENSE_PRIVATE_KEY" // issue-token: RSA private key PEM path (paired with public key)
|
||||
EnvLicensePubKeyPath = "YAMA_LICENSE_PUBLIC_KEY" // LocalSigner-as-LS: RSA public key PEM path
|
||||
EnvLicenseHTTPAddr = "YAMA_LICENSE_HTTP_ADDR" // LocalSigner-as-LS: listen address, e.g. ":8443"
|
||||
EnvLicenseStatePath = "YAMA_LICENSE_STATE_PATH" // LocalSigner-as-LS: quota state persistence file path
|
||||
EnvLicenseOfflineHrs = "YAMA_LICENSE_OFFLINE_HRS" // RemoteSigner: cache TTL hours (default 24)
|
||||
EnvLicenseDisabled = "YAMA_LICENSE_DISABLED" // set to 1 to force NoOpSigner (offline / dev)
|
||||
)
|
||||
|
||||
// DefaultLicenseServerURL is the publicly-hosted License Server new downstream
|
||||
// deployments hit when YAMA_LICENSE_SERVER is unset. "Zero config" trial mode
|
||||
// uses this URL with no Bearer token — the License Server treats it as an
|
||||
// anonymous trial (cap FreeMaxDevices, identified by source IP).
|
||||
const DefaultLicenseServerURL = "https://web.just-do-it.icu:8080"
|
||||
|
||||
// DefaultOfflineGrace mirrors the "24 hours" decision recorded in the
|
||||
// project memory's licensing design.
|
||||
const DefaultOfflineGrace = 24 * time.Hour
|
||||
@@ -28,6 +37,7 @@ type Mode int
|
||||
const (
|
||||
ModeLocal Mode = iota
|
||||
ModeRemote
|
||||
ModeTrial // RemoteSigner against License Server, but with no Bearer (anonymous trial)
|
||||
ModeNoOp
|
||||
)
|
||||
|
||||
@@ -37,6 +47,8 @@ func (m Mode) String() string {
|
||||
return "local"
|
||||
case ModeRemote:
|
||||
return "remote"
|
||||
case ModeTrial:
|
||||
return "trial"
|
||||
default:
|
||||
return "noop"
|
||||
}
|
||||
@@ -48,16 +60,25 @@ func SelectedMode() Mode {
|
||||
if os.Getenv(EnvSignPassword) != "" {
|
||||
return ModeLocal
|
||||
}
|
||||
if os.Getenv(EnvLicenseServer) != "" && os.Getenv(EnvLicenseToken) != "" {
|
||||
if strings.TrimSpace(os.Getenv(EnvLicenseDisabled)) == "1" {
|
||||
return ModeNoOp
|
||||
}
|
||||
if os.Getenv(EnvLicenseToken) != "" {
|
||||
return ModeRemote
|
||||
}
|
||||
return ModeNoOp
|
||||
return ModeTrial
|
||||
}
|
||||
|
||||
// NewFromEnv builds the Signer chosen by env vars:
|
||||
// - YAMA_SIGN_PASSWORD set → LocalSigner
|
||||
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
|
||||
// - neither → NoOpSigner
|
||||
// NewFromEnv builds the Signer chosen by env vars. Decision tree (top-down,
|
||||
// first match wins):
|
||||
//
|
||||
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator deployment)
|
||||
// - YAMA_LICENSE_DISABLED=1 → NoOpSigner (explicit opt-out)
|
||||
// - YAMA_LICENSE_TOKEN set → RemoteSigner / paid (server URL
|
||||
// defaults to DefaultLicenseServerURL
|
||||
// if YAMA_LICENSE_SERVER is unset)
|
||||
// - neither of the above → RemoteSigner / trial (anonymous,
|
||||
// cap = FreeMaxDevices, default URL)
|
||||
//
|
||||
// If both LocalSigner and RemoteSigner vars are set, LocalSigner wins
|
||||
// (an operator's own server should never accidentally call out to itself).
|
||||
@@ -82,36 +103,43 @@ func NewFromEnv(lg Logger) (Signer, Mode, error) {
|
||||
return s, ModeLocal, nil
|
||||
}
|
||||
|
||||
if server != "" && token != "" {
|
||||
if err := ValidateRemoteURL(server); err != nil {
|
||||
return nil, ModeNoOp, fmt.Errorf("%s rejected: %w", EnvLicenseServer, err)
|
||||
// Explicit opt-out: operator wants the binary to run with no licensing
|
||||
// at all (dev, offline test, air-gapped). Screen/file features stay off
|
||||
// on the client, device list still works.
|
||||
if strings.TrimSpace(os.Getenv(EnvLicenseDisabled)) == "1" {
|
||||
return NewNoOp(), ModeNoOp, nil
|
||||
}
|
||||
|
||||
// From here on we're going to talk to a License Server. Determine the
|
||||
// URL (env var wins over baked-in default) and the mode (paid if token
|
||||
// is set, anonymous trial if not).
|
||||
if server == "" {
|
||||
server = DefaultLicenseServerURL
|
||||
}
|
||||
if err := ValidateRemoteURL(server); err != nil {
|
||||
return nil, ModeNoOp, fmt.Errorf("%s rejected: %w", EnvLicenseServer, err)
|
||||
}
|
||||
|
||||
grace := DefaultOfflineGrace
|
||||
if hrs := os.Getenv(EnvLicenseOfflineHrs); hrs != "" {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(hrs))
|
||||
if err != nil {
|
||||
return nil, ModeNoOp, fmt.Errorf(
|
||||
"%s must be an integer (hours), got %q", EnvLicenseOfflineHrs, hrs)
|
||||
}
|
||||
grace := DefaultOfflineGrace
|
||||
if hrs := os.Getenv(EnvLicenseOfflineHrs); hrs != "" {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(hrs))
|
||||
if err != nil {
|
||||
return nil, ModeNoOp, fmt.Errorf(
|
||||
"%s must be an integer (hours), got %q", EnvLicenseOfflineHrs, hrs)
|
||||
}
|
||||
if n < 0 {
|
||||
return nil, ModeNoOp, fmt.Errorf(
|
||||
"%s must be >= 0, got %d", EnvLicenseOfflineHrs, n)
|
||||
}
|
||||
grace = time.Duration(n) * time.Hour
|
||||
if n < 0 {
|
||||
return nil, ModeNoOp, fmt.Errorf(
|
||||
"%s must be >= 0, got %d", EnvLicenseOfflineHrs, n)
|
||||
}
|
||||
grace = time.Duration(n) * time.Hour
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
return NewRemote(server, token, grace, lg), ModeRemote, nil
|
||||
}
|
||||
|
||||
if server != "" || token != "" {
|
||||
// Partial config is almost certainly a misconfiguration — fail loudly
|
||||
// rather than silently degrading to NoOp.
|
||||
return nil, ModeNoOp, fmt.Errorf(
|
||||
"%s and %s must be set together (got %s=%q %s=%q)",
|
||||
EnvLicenseServer, EnvLicenseToken,
|
||||
EnvLicenseServer, server, EnvLicenseToken, token)
|
||||
}
|
||||
|
||||
return NewNoOp(), ModeNoOp, nil
|
||||
// Anonymous trial: no Bearer token. License Server identifies by IP and
|
||||
// caps at FreeMaxDevices.
|
||||
return NewRemote(server, "", grace, lg), ModeTrial, nil
|
||||
}
|
||||
|
||||
// LicenseServerFromEnv builds the License Server HTTP handler if (and only
|
||||
@@ -150,5 +178,15 @@ func LicenseServerFromEnv(signer Signer, lg Logger) (*LicenseServer, string, err
|
||||
|
||||
// 5-minute eviction window — twice a typical heartbeat interval. Matches
|
||||
// the discussion in quota.go.
|
||||
return NewLicenseServer(local, pubKey, 5*time.Minute, lg), addr, nil
|
||||
ls := NewLicenseServer(local, pubKey, 5*time.Minute, lg, os.Getenv(EnvLicenseStatePath))
|
||||
|
||||
// Reuse the web's trust-proxy env var: standard deployment puts both
|
||||
// /ws and /license/ behind the same nginx, so the answer is always the
|
||||
// same. Honoring it here lets the anonymous-trial per-IP rate limit see
|
||||
// the real client IP instead of 127.0.0.1.
|
||||
if strings.TrimSpace(os.Getenv("YAMA_WEB_TRUST_PROXY")) == "1" {
|
||||
ls.SetTrustProxy(true)
|
||||
}
|
||||
|
||||
return ls, addr, nil
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ func TestLocalSignerDeterministic(t *testing.T) {
|
||||
func TestRemoteSignerCacheHit(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "real-hmac-key-for-test-xx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestRemoteSignerCacheHit(t *testing.T) {
|
||||
func TestRemoteSignerStaleFallback(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "master-fallback-test-xxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
|
||||
tok, err := Issue(priv, "cust-fallback", TierPaid, 5, time.Hour)
|
||||
@@ -214,7 +214,7 @@ func TestRemoteSignerStaleFallback(t *testing.T) {
|
||||
func TestQuotaEnforcement(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "master-quota-test-xxxxxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -253,23 +253,102 @@ func TestQuotaEnforcement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthRejectsMissingBearer: no token → 401, not 200 / not 500. Belt
|
||||
// and braces — the auth check sits in front of /sign and /heartbeat.
|
||||
func TestAuthRejectsMissingBearer(t *testing.T) {
|
||||
// TestAnonymousTrialSignsAndCaps: no Authorization header → anonymous trial
|
||||
// branch. /sign returns 200 with a real signature up to FreeMaxDevices, then
|
||||
// 403 once the per-IP cap is reached. Replaces the older "missing bearer →
|
||||
// 401" test now that anonymous trial is a first-class mode.
|
||||
func TestAnonymousTrialSignsAndCaps(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "master-auth-test-xxxxxxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||
master := mustLocal(t, "master-trial-test-xxxxxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
body := strings.NewReader(`{"client_id":"x","start_time":"y"}`)
|
||||
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
|
||||
call := func(clientID string) (int, string) {
|
||||
body := strings.NewReader(fmt.Sprintf(
|
||||
`{"client_id":%q,"start_time":"2026-01-01T00:00:00Z"}`, clientID))
|
||||
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatalf("Post: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var sr signResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&sr)
|
||||
if sr.Signature != "" {
|
||||
return resp.StatusCode, sr.Signature
|
||||
}
|
||||
return resp.StatusCode, sr.Error
|
||||
}
|
||||
|
||||
// First FreeMaxDevices distinct clientIDs get real signatures.
|
||||
for i := range FreeMaxDevices {
|
||||
code, sig := call(fmt.Sprintf("trial-dev-%d", i))
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("dev-%d expected 200, got %d (%q)", i, code, sig)
|
||||
}
|
||||
if sig == "" {
|
||||
t.Errorf("dev-%d signature unexpectedly empty", i)
|
||||
}
|
||||
}
|
||||
// Cap+1 → 403 quota exceeded.
|
||||
code, msg := call("trial-dev-overflow")
|
||||
if code != http.StatusForbidden {
|
||||
t.Errorf("overflow expected 403, got %d (%q)", code, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymousTrialIPRateLimit: anonymous /sign is capped at
|
||||
// anonRatePerWindow requests per minute per source IP. Hitting the cap
|
||||
// returns 429 with Retry-After.
|
||||
func TestAnonymousTrialIPRateLimit(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "master-rate-test-xxxxxxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
// Reuse the same clientID so quota does NOT also reject — we want to
|
||||
// isolate the rate limiter. quotaTracker.Reserve treats a repeat clientID
|
||||
// as a refresh (always accepted), so all the 200s here are the same slot.
|
||||
hit := func() int {
|
||||
body := strings.NewReader(`{"client_id":"rate-dev","start_time":"t"}`)
|
||||
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatalf("Post: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
for i := range anonRatePerWindow {
|
||||
if code := hit(); code != http.StatusOK {
|
||||
t.Fatalf("req %d expected 200, got %d", i, code)
|
||||
}
|
||||
}
|
||||
if code := hit(); code != http.StatusTooManyRequests {
|
||||
t.Errorf("expected 429 after %d requests, got %d", anonRatePerWindow, code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthRejectsBadBearer: invalid JWT still returns 401 (we did NOT widen
|
||||
// the auth surface; only "no Authorization header at all" enters trial).
|
||||
func TestAuthRejectsBadBearer(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "master-bad-bearer-xxxxxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/license/sign",
|
||||
strings.NewReader(`{"client_id":"x","start_time":"y"}`))
|
||||
req.Header.Set("Authorization", "Bearer not.a.real.jwt")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Post: %v", err)
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", resp.StatusCode)
|
||||
t.Errorf("expected 401 for malformed bearer, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +378,7 @@ func TestRemoteSignerHardFailNoCacheReturnsError(t *testing.T) {
|
||||
func TestHeartbeatRefreshOnly(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "master-hb-test-xxxxxxxxxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -382,7 +461,7 @@ func TestHeartbeatRefreshOnly(t *testing.T) {
|
||||
func TestQuotaRejectionDoesNotConsumeSlot(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "master-no-leak-xxxxxxxxxxxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -518,3 +597,36 @@ func TestJWTAlgLockedToRS256(t *testing.T) {
|
||||
t.Error("VerifyJWT accepted RS384; alg should be locked to RS256")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaTrackerPersistence: after a simulated restart (new tracker loaded
|
||||
// from the file written by the first), previously-admitted devices re-occupy
|
||||
// their slots and a new over-quota device is still rejected.
|
||||
func TestQuotaTrackerPersistence(t *testing.T) {
|
||||
path := t.TempDir() + "/quota.json"
|
||||
|
||||
// First "run": admit dev-1 and dev-2 up to cap=2.
|
||||
q1 := newQuotaTracker(5 * time.Minute)
|
||||
q1.statePath = path
|
||||
if _, ok := q1.Reserve("sub", "dev-1", 2); !ok {
|
||||
t.Fatal("dev-1 should be admitted")
|
||||
}
|
||||
if _, ok := q1.Reserve("sub", "dev-2", 2); !ok {
|
||||
t.Fatal("dev-2 should be admitted")
|
||||
}
|
||||
|
||||
// Simulate restart: new tracker loads the persisted file.
|
||||
q2 := newQuotaTracker(5 * time.Minute)
|
||||
q2.statePath = path
|
||||
if err := q2.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
// Restored tracker knows about dev-1 and dev-2: quota full.
|
||||
if count, ok := q2.Reserve("sub", "dev-3", 2); ok {
|
||||
t.Errorf("dev-3 should be rejected after restore, count=%d", count)
|
||||
}
|
||||
// Existing devices re-sign successfully (idempotent refresh).
|
||||
if _, ok := q2.Reserve("sub", "dev-1", 2); !ok {
|
||||
t.Error("dev-1 re-sign should succeed after restore")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -22,20 +24,31 @@ const (
|
||||
TrialMaxDevices = 20
|
||||
)
|
||||
|
||||
// persistedQuota is the on-disk snapshot format. V=1 is the current schema.
|
||||
type persistedQuota struct {
|
||||
V int `json:"v"` // schema version
|
||||
Customers map[string][]string `json:"customers"` // sub → []clientID
|
||||
}
|
||||
|
||||
// quotaTracker maintains the active-device set per customer. Customers are
|
||||
// identified by the JWT "sub" claim. The set is keyed by clientID (uint64
|
||||
// from the device, stringified) — same device coming back through the
|
||||
// same License Server is one slot, not two.
|
||||
//
|
||||
// Eviction: any clientID not seen in /sign or /license/heartbeat within
|
||||
// the eviction window is silently dropped from the active set. This stops
|
||||
// a never-heartbeating customer from holding slots forever. Default
|
||||
// window is twice the heartbeat interval the customer reports at (5 min).
|
||||
// the eviction window is silently dropped from the active set. Default
|
||||
// window is 5 minutes (twice the heartbeat interval).
|
||||
//
|
||||
// Empty customer entries are reaped at the end of each mutation so the
|
||||
// outer map doesn't accumulate sub claims of expired contracts.
|
||||
// Persistence: when statePath is set, the sub→clientID map is written
|
||||
// atomically to disk on every structural change (device added or evicted).
|
||||
// Load() restores the state on startup with fresh timestamps so a License
|
||||
// Server restart does not open a quota-bypass window.
|
||||
//
|
||||
// Empty customer entries are reaped at the end of each mutation.
|
||||
type quotaTracker struct {
|
||||
evictAfter time.Duration
|
||||
statePath string // "" = no persistence
|
||||
log Logger // nil = silent
|
||||
|
||||
mu sync.Mutex
|
||||
customer map[string]*customerState // sub claim → state
|
||||
@@ -52,14 +65,105 @@ func newQuotaTracker(evictAfter time.Duration) *quotaTracker {
|
||||
}
|
||||
}
|
||||
|
||||
// evictLocked drops stale entries from st.devices. Caller must hold q.mu.
|
||||
func (q *quotaTracker) evictLocked(st *customerState) {
|
||||
// Load reads the persisted state from statePath and restores each clientID
|
||||
// with timestamp time.Now() so restored devices survive the initial eviction
|
||||
// window long enough to heartbeat or re-sign. A missing or corrupt file is
|
||||
// silently ignored so the server starts cleanly on first run.
|
||||
func (q *quotaTracker) Load() error {
|
||||
if q.statePath == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(q.statePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var p persistedQuota
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
if q.log != nil {
|
||||
q.log.Warn("quota: corrupt state file %s (starting empty): %v", q.statePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
now := time.Now()
|
||||
restored := 0
|
||||
for sub, ids := range p.Customers {
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
st := &customerState{devices: make(map[string]time.Time, len(ids))}
|
||||
for _, cid := range ids {
|
||||
st.devices[cid] = now
|
||||
restored++
|
||||
}
|
||||
q.customer[sub] = st
|
||||
}
|
||||
if q.log != nil && restored > 0 {
|
||||
q.log.Info("quota: restored %d device slot(s) from %s", restored, q.statePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// snapshotLocked returns a sub→[]clientID map of the current state.
|
||||
// Caller must hold q.mu.
|
||||
func (q *quotaTracker) snapshotLocked() map[string][]string {
|
||||
out := make(map[string][]string, len(q.customer))
|
||||
for sub, st := range q.customer {
|
||||
if len(st.devices) == 0 {
|
||||
continue
|
||||
}
|
||||
ids := make([]string, 0, len(st.devices))
|
||||
for cid := range st.devices {
|
||||
ids = append(ids, cid)
|
||||
}
|
||||
out[sub] = ids
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// save writes snap atomically (temp file + rename). No-op when statePath is
|
||||
// empty or snap is nil.
|
||||
func (q *quotaTracker) save(snap map[string][]string) {
|
||||
if q.statePath == "" || snap == nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(persistedQuota{V: 1, Customers: snap})
|
||||
if err != nil {
|
||||
if q.log != nil {
|
||||
q.log.Warn("quota: marshal state: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
tmp := q.statePath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
||||
if q.log != nil {
|
||||
q.log.Warn("quota: write state to %s: %v", tmp, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp, q.statePath); err != nil {
|
||||
if q.log != nil {
|
||||
q.log.Warn("quota: rename %s → %s: %v", tmp, q.statePath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evictLocked drops stale entries from st.devices. Returns the number removed.
|
||||
// Caller must hold q.mu.
|
||||
func (q *quotaTracker) evictLocked(st *customerState) int {
|
||||
cutoff := time.Now().Add(-q.evictAfter)
|
||||
removed := 0
|
||||
for cid, last := range st.devices {
|
||||
if last.Before(cutoff) {
|
||||
delete(st.devices, cid)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// reapEmptyLocked deletes sub entries whose device sets are empty. This
|
||||
@@ -84,7 +188,6 @@ func (q *quotaTracker) reapEmptyLocked(sub string) {
|
||||
// re-signing is never a quota violation — caps only apply to ADDING).
|
||||
func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
st, ok := q.customer[sub]
|
||||
if !ok {
|
||||
@@ -92,21 +195,38 @@ func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool)
|
||||
q.customer[sub] = st
|
||||
}
|
||||
|
||||
q.evictLocked(st)
|
||||
evicted := q.evictLocked(st)
|
||||
|
||||
if _, already := st.devices[clientID]; already {
|
||||
st.devices[clientID] = time.Now()
|
||||
return len(st.devices), true
|
||||
count := len(st.devices)
|
||||
var snap map[string][]string
|
||||
if evicted > 0 {
|
||||
snap = q.snapshotLocked()
|
||||
}
|
||||
q.mu.Unlock()
|
||||
q.save(snap)
|
||||
return count, true
|
||||
}
|
||||
|
||||
if len(st.devices)+1 > maxDevices {
|
||||
// Don't reap on rejection — the customer might be at exactly cap
|
||||
// with valid devices, and an empty map would lose info.
|
||||
return len(st.devices), false
|
||||
count := len(st.devices)
|
||||
var snap map[string][]string
|
||||
if evicted > 0 {
|
||||
snap = q.snapshotLocked()
|
||||
}
|
||||
q.mu.Unlock()
|
||||
q.save(snap)
|
||||
return count, false
|
||||
}
|
||||
|
||||
// New device admitted: always persist so a restart sees this slot.
|
||||
st.devices[clientID] = time.Now()
|
||||
return len(st.devices), true
|
||||
count := len(st.devices)
|
||||
snap := q.snapshotLocked()
|
||||
q.mu.Unlock()
|
||||
q.save(snap)
|
||||
return count, true
|
||||
}
|
||||
|
||||
// RefreshExisting bumps the last-activity timestamp for any clientID in
|
||||
@@ -118,14 +238,14 @@ func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool)
|
||||
// known to us from a prior Reserve).
|
||||
func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
st, ok := q.customer[sub]
|
||||
if !ok {
|
||||
q.mu.Unlock()
|
||||
return 0
|
||||
}
|
||||
|
||||
q.evictLocked(st)
|
||||
evicted := q.evictLocked(st)
|
||||
|
||||
now := time.Now()
|
||||
refreshed := 0
|
||||
@@ -137,6 +257,13 @@ func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
|
||||
}
|
||||
|
||||
q.reapEmptyLocked(sub) // eviction may have emptied us
|
||||
|
||||
var snap map[string][]string
|
||||
if evicted > 0 {
|
||||
snap = q.snapshotLocked()
|
||||
}
|
||||
q.mu.Unlock()
|
||||
q.save(snap)
|
||||
return refreshed
|
||||
}
|
||||
|
||||
@@ -144,16 +271,25 @@ func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
|
||||
// /license/heartbeat to report the server-side view.
|
||||
func (q *quotaTracker) Snapshot(sub string) []string {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
st, ok := q.customer[sub]
|
||||
if !ok {
|
||||
q.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
q.evictLocked(st)
|
||||
|
||||
evicted := q.evictLocked(st)
|
||||
out := make([]string, 0, len(st.devices))
|
||||
for cid := range st.devices {
|
||||
out = append(out, cid)
|
||||
}
|
||||
q.reapEmptyLocked(sub)
|
||||
|
||||
var snap map[string][]string
|
||||
if evicted > 0 {
|
||||
snap = q.snapshotLocked()
|
||||
}
|
||||
q.mu.Unlock()
|
||||
q.save(snap)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -16,6 +16,25 @@ import (
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// QuotaExceededError is returned by Sign when the License Server explicitly
|
||||
// rejects the device because the customer's slot quota is full. Unlike
|
||||
// transient network errors, stale-cache fallback is NOT appropriate — the
|
||||
// License Server's 403 decision is authoritative, and serving a stale
|
||||
// signature would silently bypass the operator's cap.
|
||||
type QuotaExceededError struct {
|
||||
Message string // raw error field from the License Server JSON body
|
||||
}
|
||||
|
||||
func (e *QuotaExceededError) Error() string { return e.Message }
|
||||
|
||||
// IsQuotaExceeded reports whether err is, or wraps, a QuotaExceededError.
|
||||
// Callers (e.g. handleLogin in cmd/main.go) use this to decide whether to
|
||||
// close the device connection server-side after sending a zeroed signature.
|
||||
func IsQuotaExceeded(err error) bool {
|
||||
var qe *QuotaExceededError
|
||||
return errors.As(err, &qe)
|
||||
}
|
||||
|
||||
// RemoteSigner fetches per-login signatures from an operator-hosted License
|
||||
// Server. ServerURL and Token (a JWT issued offline by the operator) are
|
||||
// loaded from YAMA_LICENSE_SERVER / YAMA_LICENSE_TOKEN at startup.
|
||||
@@ -153,8 +172,17 @@ func (r *RemoteSigner) Sign(startTime, clientID string) (string, error) {
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
// Hard failure: fall back to stale cache if any. Better to keep an
|
||||
// existing device alive than fail closed during a transient outage.
|
||||
// Quota-exceeded is authoritative — skip stale-cache fallback entirely.
|
||||
// Serving a cached signature here would bypass the operator's explicit cap
|
||||
// decision; the caller (handleLogin) should close the connection instead.
|
||||
if IsQuotaExceeded(err) {
|
||||
r.logger.Error("RemoteSigner: quota exceeded for clientID=%s (%v); sending zeroed signature",
|
||||
clientID, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Transient failure: fall back to stale cache if any. Better to keep an
|
||||
// existing device alive than fail closed during a momentary outage.
|
||||
r.mu.Lock()
|
||||
c, ok := r.cache[key]
|
||||
r.mu.Unlock()
|
||||
@@ -179,7 +207,9 @@ func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||
if r.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
@@ -194,7 +224,17 @@ func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// 401/403: token rejected — likely revoked or expired.
|
||||
// 403 with a JSON error body: quota exceeded — this is authoritative,
|
||||
// not a transient failure. Return a typed error so Sign() can skip
|
||||
// the stale-cache fallback and callers can close the connection.
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
var sr signResponse
|
||||
if jsonErr := json.Unmarshal(respBody, &sr); jsonErr == nil && sr.Error != "" {
|
||||
return "", &QuotaExceededError{Message: sr.Error}
|
||||
}
|
||||
return "", &QuotaExceededError{Message: string(respBody)}
|
||||
}
|
||||
// 401 / 5xx: token rejected or server error — treat as transient.
|
||||
return "", fmt.Errorf("License Server returned %d: %s",
|
||||
resp.StatusCode, string(respBody))
|
||||
}
|
||||
@@ -265,7 +305,9 @@ func (r *RemoteSigner) sendHeartbeat() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||
if r.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
@@ -280,7 +322,15 @@ func (r *RemoteSigner) sendHeartbeat() {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RemoteSigner) Mode() string { return "remote" }
|
||||
// Mode reports "trial" if this RemoteSigner has no JWT (anonymous
|
||||
// downstream against the operator's License Server, capped at
|
||||
// FreeMaxDevices), otherwise "remote" (paid customer with JWT).
|
||||
func (r *RemoteSigner) Mode() string {
|
||||
if r.token == "" {
|
||||
return "trial"
|
||||
}
|
||||
return "remote"
|
||||
}
|
||||
|
||||
func (r *RemoteSigner) Close() error {
|
||||
select {
|
||||
|
||||
@@ -5,13 +5,36 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Anonymous-trial rate limit: per-source-IP cap on /license/sign +
|
||||
// /license/heartbeat requests without a Bearer token. Picked at "high enough
|
||||
// for any legitimate single deployment, low enough to make brute-force /
|
||||
// signature-probing pointless." Each /sign costs 1, /heartbeat 1, in a 60s
|
||||
// sliding window. Authenticated requests skip this.
|
||||
const (
|
||||
anonRatePerWindow = 10
|
||||
anonRateWindow = time.Minute
|
||||
// anonReapInterval throws away stale buckets so the map doesn't grow
|
||||
// unbounded across IP-cycling attackers. Walk the map every N requests.
|
||||
anonReapEvery = 200
|
||||
)
|
||||
|
||||
// Log-throttle cooldowns: downstream devices reconnect every few seconds when
|
||||
// over-quota, so without throttling these two Warn lines flood the operator's
|
||||
// log file. One log entry per cooldown window per unique key is enough signal.
|
||||
const (
|
||||
quotaWarnCooldown = 5 * time.Minute // per (sub, clientID) pair
|
||||
rlWarnCooldown = anonRateWindow // per IP, matches the rate-limit window
|
||||
)
|
||||
|
||||
// LicenseServer is the HTTP service the operator's LocalSigner exposes for
|
||||
// RemoteSigner customer deployments. It uses the same LocalSigner instance
|
||||
// (same HMAC master key) to produce signatures so customers can issue
|
||||
@@ -37,12 +60,32 @@ import (
|
||||
//
|
||||
// Security: serve plain HTTP and put nginx / Caddy in front for TLS. JWT
|
||||
// "alg" is locked to RS256 in token.go; "alg":"none" tampering is blocked.
|
||||
//
|
||||
// Anonymous trial: requests without a Bearer token are treated as anonymous
|
||||
// trial — the client's source IP is used as "sub" (key=`trial:<ip>`) and
|
||||
// MaxDevices is capped at FreeMaxDevices. This is what lets a zero-config
|
||||
// downstream binary "just work" for evaluation. Heavily rate-limited per IP
|
||||
// to make brute-force / signature-probing pointless.
|
||||
type LicenseServer struct {
|
||||
signer *LocalSigner
|
||||
pubKey *rsa.PublicKey
|
||||
tracker *quotaTracker
|
||||
logger Logger
|
||||
mux *http.ServeMux
|
||||
signer *LocalSigner
|
||||
pubKey *rsa.PublicKey
|
||||
tracker *quotaTracker
|
||||
logger Logger
|
||||
mux *http.ServeMux
|
||||
trustProxy bool // honor X-Forwarded-For / X-Real-IP — set only behind a trusted reverse proxy
|
||||
|
||||
anonMu sync.Mutex
|
||||
anonBuckets map[string]*anonBucket // ip → bucket
|
||||
anonReqSeen int // counter for periodic reap
|
||||
|
||||
warnMu sync.Mutex
|
||||
lastWarn map[string]time.Time // dedup key → last log time
|
||||
}
|
||||
|
||||
// anonBucket tracks anonymous request count within a sliding window.
|
||||
type anonBucket struct {
|
||||
count int
|
||||
windowStart time.Time
|
||||
}
|
||||
|
||||
// Logger is the minimal logging interface we need. The cmd package's
|
||||
@@ -57,19 +100,50 @@ type Logger interface {
|
||||
// quiet device keeps its slot before its quota is reclaimed (recommend
|
||||
// 5 min — twice a typical heartbeat interval).
|
||||
func NewLicenseServer(signer *LocalSigner, pubKey *rsa.PublicKey,
|
||||
evictAfter time.Duration, lg Logger) *LicenseServer {
|
||||
evictAfter time.Duration, lg Logger, statePath string) *LicenseServer {
|
||||
qt := newQuotaTracker(evictAfter)
|
||||
qt.statePath = statePath
|
||||
qt.log = lg
|
||||
if err := qt.Load(); err != nil && lg != nil {
|
||||
lg.Warn("License Server: failed to load quota state from %s: %v", statePath, err)
|
||||
}
|
||||
s := &LicenseServer{
|
||||
signer: signer,
|
||||
pubKey: pubKey,
|
||||
tracker: newQuotaTracker(evictAfter),
|
||||
logger: lg,
|
||||
mux: http.NewServeMux(),
|
||||
signer: signer,
|
||||
pubKey: pubKey,
|
||||
tracker: qt,
|
||||
logger: lg,
|
||||
mux: http.NewServeMux(),
|
||||
anonBuckets: make(map[string]*anonBucket),
|
||||
lastWarn: make(map[string]time.Time),
|
||||
}
|
||||
s.mux.HandleFunc("/license/sign", s.handleSign)
|
||||
s.mux.HandleFunc("/license/heartbeat", s.handleHeartbeat)
|
||||
return s
|
||||
}
|
||||
|
||||
// warnOnce emits a Warn log at most once per cooldown window for the given
|
||||
// dedup key. Subsequent identical events within the window are silently
|
||||
// dropped. This keeps high-frequency but expected conditions (quota exceeded,
|
||||
// rate limit hit) from flooding the operator's log file while still providing
|
||||
// one clear signal per event burst.
|
||||
func (s *LicenseServer) warnOnce(key string, cooldown time.Duration, format string, args ...any) {
|
||||
s.warnMu.Lock()
|
||||
if t, ok := s.lastWarn[key]; ok && time.Since(t) < cooldown {
|
||||
s.warnMu.Unlock()
|
||||
return
|
||||
}
|
||||
s.lastWarn[key] = time.Now()
|
||||
s.warnMu.Unlock()
|
||||
s.logger.Warn(format, args...)
|
||||
}
|
||||
|
||||
// SetTrustProxy switches IP extraction to X-Forwarded-For / X-Real-IP for
|
||||
// the anonymous-trial branch. Only set this when running behind a reverse
|
||||
// proxy you control (nginx / caddy / cloudflare); direct-exposure
|
||||
// deployments MUST leave it false or attackers can spoof the header to
|
||||
// evade the per-IP rate limit and the trial quota.
|
||||
func (s *LicenseServer) SetTrustProxy(trust bool) { s.trustProxy = trust }
|
||||
|
||||
// Handler returns the http.Handler the operator wires into their HTTP
|
||||
// server (or runs standalone via http.ListenAndServe).
|
||||
func (s *LicenseServer) Handler() http.Handler { return s.mux }
|
||||
@@ -92,13 +166,107 @@ func (s *LicenseServer) authenticate(w http.ResponseWriter, r *http.Request) *Li
|
||||
return claims
|
||||
}
|
||||
|
||||
// resolveAuth decides whether the request is paid (Bearer JWT) or anonymous
|
||||
// trial (no Authorization header). Returns a LicenseClaims structure either
|
||||
// way:
|
||||
// - Paid: claims from JWT, untouched.
|
||||
// - Trial: synthesized claims with Subject="trial:<ip>", Tier=TierFree,
|
||||
// MaxDevices=FreeMaxDevices, no Bearer required.
|
||||
//
|
||||
// Anonymous requests are rate-limited per source IP; if the IP's bucket is
|
||||
// full we write 429 and return nil. Bad JWTs still 401 as before.
|
||||
func (s *LicenseServer) resolveAuth(w http.ResponseWriter, r *http.Request) *LicenseClaims {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
return s.authenticate(w, r)
|
||||
}
|
||||
|
||||
// Anonymous trial branch.
|
||||
ip := s.clientIP(r)
|
||||
if !s.allowAnon(ip) {
|
||||
s.warnOnce("rl:"+ip, rlWarnCooldown, "License Server: anonymous rate limit hit for ip=%s", ip)
|
||||
w.Header().Set("Retry-After", "60")
|
||||
writeJSONError(w, http.StatusTooManyRequests,
|
||||
"trial rate limit exceeded; set YAMA_LICENSE_TOKEN for full license")
|
||||
return nil
|
||||
}
|
||||
|
||||
return &LicenseClaims{
|
||||
Tier: TierFree,
|
||||
MaxDevices: FreeMaxDevices,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "trial:" + ip,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP returns the request's source IP. When trustProxy is set, prefer
|
||||
// X-Real-IP, then the last entry of X-Forwarded-For. Otherwise fall back to
|
||||
// r.RemoteAddr (host part only).
|
||||
func (s *LicenseServer) clientIP(r *http.Request) string {
|
||||
if s.trustProxy {
|
||||
if v := strings.TrimSpace(r.Header.Get("X-Real-IP")); v != "" {
|
||||
return v
|
||||
}
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// Last entry is the one appended by the proxy closest to us.
|
||||
parts := strings.Split(xff, ",")
|
||||
last := strings.TrimSpace(parts[len(parts)-1])
|
||||
if last != "" {
|
||||
return last
|
||||
}
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// allowAnon enforces the anonymous-trial per-IP rate limit. Returns true
|
||||
// when the call is admitted, false when the IP's bucket is full. Also reaps
|
||||
// stale buckets opportunistically.
|
||||
func (s *LicenseServer) allowAnon(ip string) bool {
|
||||
s.anonMu.Lock()
|
||||
defer s.anonMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
b, ok := s.anonBuckets[ip]
|
||||
if !ok || now.Sub(b.windowStart) >= anonRateWindow {
|
||||
s.anonBuckets[ip] = &anonBucket{count: 1, windowStart: now}
|
||||
s.maybeReapAnonLocked(now)
|
||||
return true
|
||||
}
|
||||
if b.count >= anonRatePerWindow {
|
||||
return false
|
||||
}
|
||||
b.count++
|
||||
return true
|
||||
}
|
||||
|
||||
// maybeReapAnonLocked drops buckets whose windows are stale every N requests.
|
||||
// Caller must hold s.anonMu.
|
||||
func (s *LicenseServer) maybeReapAnonLocked(now time.Time) {
|
||||
s.anonReqSeen++
|
||||
if s.anonReqSeen < anonReapEvery {
|
||||
return
|
||||
}
|
||||
s.anonReqSeen = 0
|
||||
cutoff := now.Add(-anonRateWindow)
|
||||
for ip, b := range s.anonBuckets {
|
||||
if b.windowStart.Before(cutoff) {
|
||||
delete(s.anonBuckets, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
claims := s.authenticate(w, r)
|
||||
claims := s.resolveAuth(w, r)
|
||||
if claims == nil {
|
||||
return
|
||||
}
|
||||
@@ -117,7 +285,8 @@ func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) {
|
||||
// consume a slot — see quotaTracker.Reserve.
|
||||
active, accepted := s.tracker.Reserve(claims.Subject, req.ClientID, claims.MaxDevices)
|
||||
if !accepted {
|
||||
s.logger.Warn("License Server: quota exceeded for sub=%s tier=%s active=%d max=%d clientID=%s",
|
||||
s.warnOnce("quota:"+claims.Subject+":"+req.ClientID, quotaWarnCooldown,
|
||||
"License Server: quota exceeded for sub=%s tier=%s active=%d max=%d clientID=%s",
|
||||
claims.Subject, claims.Tier, active, claims.MaxDevices, req.ClientID)
|
||||
writeJSONError(w, http.StatusForbidden,
|
||||
fmt.Sprintf("quota exceeded: %d/%d devices in use", active, claims.MaxDevices))
|
||||
@@ -154,7 +323,7 @@ func (s *LicenseServer) handleHeartbeat(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
claims := s.authenticate(w, r)
|
||||
claims := s.resolveAuth(w, r)
|
||||
if claims == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -23,6 +23,34 @@ type LicenseClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// LoadRSAPrivateKey parses an RSA private key from a PEM file. Used by the
|
||||
// "issue-token" CLI subcommand to sign customer JWTs offline.
|
||||
// Accepts PKCS#1 ("RSA PRIVATE KEY") and PKCS#8 ("PRIVATE KEY") PEM encodings.
|
||||
func LoadRSAPrivateKey(pemPath string) (*rsa.PrivateKey, error) {
|
||||
data, err := os.ReadFile(pemPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read private key %s: %w", pemPath, err)
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block in %s", pemPath)
|
||||
}
|
||||
|
||||
// PKCS#1: "RSA PRIVATE KEY"
|
||||
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
// PKCS#8: "PRIVATE KEY"
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("PKCS#8 key in %s is not RSA", pemPath)
|
||||
}
|
||||
return rsaKey, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse %s as PKCS#1 or PKCS#8 RSA private key", pemPath)
|
||||
}
|
||||
|
||||
// LoadRSAPublicKey parses an RSA public key from a PEM file. The License
|
||||
// Server loads this once at startup to verify incoming customer JWTs.
|
||||
// Accepts both PKCS#1 ("RSA PUBLIC KEY") and PKIX ("PUBLIC KEY") PEM
|
||||
|
||||
@@ -3667,13 +3667,15 @@
|
||||
// Must send first click before dblclick for Windows to recognize
|
||||
console.log('[Touch] Double click');
|
||||
clickAtCursor(0); // First click
|
||||
// dblClickAtCursor(); // Then double click
|
||||
// 强制人工延迟 20 毫秒发送第二次标准单击
|
||||
// 这 20ms 的延迟在操作上完全感觉不到,但对远程桌面的网络和操作系统驱动至关重要!
|
||||
// 它能完美地把两次点击在时间线上拉开,让任何操作系统都 100% 判定这是标准的“物理鼠标双击”。
|
||||
setTimeout(() => {
|
||||
clickAtCursor(0);
|
||||
}, 20);
|
||||
if (currentDevice && currentDevice.clientType === 'MAC') {
|
||||
// macOS uses a real dblclick event; two sequential clicks don't work
|
||||
dblClickAtCursor(); // Then double click
|
||||
} else {
|
||||
// Windows/Linux: simulate physical double-click with two clicks 20ms apart
|
||||
setTimeout(() => {
|
||||
clickAtCursor(0);
|
||||
}, 20);
|
||||
}
|
||||
touchState.state = T_IDLE;
|
||||
} else if (touchState.state === T_FIRST_DOWN && !touchState.moved) {
|
||||
// First tap released without moving = single click
|
||||
|
||||
Reference in New Issue
Block a user