Compliance: Server-side anti-proxy for trail authorization

This commit is contained in:
yuanyuanxiang
2026-05-16 13:19:01 +02:00
parent 4279e79aa7
commit 4d2b12a9dd
11 changed files with 642 additions and 1 deletions

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