4 Commits

44 changed files with 1116 additions and 196 deletions

View File

@@ -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

View File

@@ -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_)

View File

@@ -1,6 +1,7 @@
#include "StdAfx.h"
#include "Common.h"
#include "Manager.h"
#include "ScreenManager.h"
#include "FileManager.h"
#include "TalkManager.h"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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_)

View File

@@ -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 校验

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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_)

View File

@@ -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

View File

@@ -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_)

View File

@@ -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

View File

@@ -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_)

View File

@@ -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

View File

@@ -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_)

View File

@@ -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

View File

@@ -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_)

View File

@@ -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

View File

@@ -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_)

View File

@@ -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

View File

@@ -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_)

View File

@@ -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

View File

@@ -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_)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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(

View File

@@ -627,15 +627,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 +647,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 +927,7 @@ void CBuildDlg::OnCbnSelchangeComboExe()
SAFE_DELETE_ARRAY(szBuffer);
}
} else {
m_OtherItem.SetWindowTextA("未选择文件");
m_OtherItem.SetWindowTextA(_TR("未选择文件"));
}
m_OtherItem.ShowWindow(SW_SHOW);
} else {

View File

@@ -277,7 +277,7 @@
#define BRAND_URL_REQUEST_AUTH "https://simpleremoter.com/"
// 获取插件
#define BRAND_URL_GET_PLUGIN "This feature has not been implemented!\nPlease contact: 962914132@qq.com"
#define BRAND_URL_GET_PLUGIN "https://simpleremoter.com/login"
// ============================================================
// 内部使用 - 请勿修改以下内容

View File

@@ -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);

View File

@@ -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"

View File

@@ -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 颁发的客户 JWTRS256作为 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 颁发的客户 JWTRS256作为 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 + renameLicense 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` | 20JWT 未指定时) | 移植 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

View File

@@ -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
@@ -702,11 +784,11 @@ func main() {
// 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_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")
}

View File

@@ -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) != "" {
return ModeRemote
}
if strings.TrimSpace(os.Getenv(EnvLicenseDisabled)) == "1" {
return ModeNoOp
}
if os.Getenv(EnvLicenseToken) != "" {
return ModeRemote
}
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,10 +103,23 @@ func NewFromEnv(lg Logger) (Signer, Mode, error) {
return s, ModeLocal, nil
}
if server != "" && token != "" {
// 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))
@@ -99,19 +133,13 @@ func NewFromEnv(lg Logger) (Signer, Mode, error) {
}
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
}

View File

@@ -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"}`)
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("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")
}
}

View File

@@ -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
}

View File

@@ -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
}
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
}
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 {

View File

@@ -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
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),
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
}

View File

@@ -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

View File

@@ -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% 判定这是标准的“物理鼠标双击”。
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