diff --git a/server/2015Remote/CLicenseDlg.cpp b/server/2015Remote/CLicenseDlg.cpp index 2bd7d61..f3b0e77 100644 --- a/server/2015Remote/CLicenseDlg.cpp +++ b/server/2015Remote/CLicenseDlg.cpp @@ -55,9 +55,13 @@ static bool FreeFrpPortAllocation(int port, const std::string& expectedOwner); // 获取所有授权信息 std::vector GetAllLicenses() { + std::lock_guard _lock(LicensesIniMutex()); std::vector licenses; std::string iniPath = GetLicensesPath(); + // 注意:CIniParser 走 ifstream 读取整文件,与 WritePrivateProfileString 的内核锁 + // 不在同一域。必须靠这里的 g_licensesIniMutex 阻止与其它写入交错,否则可能读到 + // 写入到一半的中间态。 CIniParser parser; if (!parser.LoadFile(iniPath.c_str())) return licenses; @@ -306,6 +310,7 @@ void CLicenseDlg::OnSize(UINT nType, int cx, int cy) // 更新授权状态 bool SetLicenseStatus(const std::string& deviceID, const std::string& status) { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); @@ -457,6 +462,7 @@ int ParseHostNumFromPasscode(const std::string& passcode) // 设置待续期信息 bool SetPendingRenewal(const std::string& deviceID, const std::string& expireDate, int hostNum, int quota) { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); @@ -475,6 +481,7 @@ bool SetPendingRenewal(const std::string& deviceID, const std::string& expireDat // 获取待续期信息 RenewalInfo GetPendingRenewal(const std::string& deviceID) { + std::lock_guard _lock(LicensesIniMutex()); RenewalInfo info; std::string iniPath = GetLicensesPath(); config cfg(iniPath); @@ -488,6 +495,7 @@ RenewalInfo GetPendingRenewal(const std::string& deviceID) // 清除待续期信息 bool ClearPendingRenewal(const std::string& deviceID) { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); @@ -498,8 +506,11 @@ bool ClearPendingRenewal(const std::string& deviceID) } // 配额递减,返回是否还有剩余配额 +// 关键:read-modify-write 的 PendingQuota 必须在锁内完成,否则与 SetPendingRenewal +// 并发会丢失用户刚设置的预设续期(旧 bug:用户报告"预设续期消失"的根因)。 bool DecrementPendingQuota(const std::string& deviceID) { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); @@ -512,7 +523,7 @@ bool DecrementPendingQuota(const std::string& deviceID) cfg.SetInt(deviceID, "PendingQuota", quota); if (quota <= 0) { - // 配额用完,清除待续期信息 + // 配额用完,清除待续期信息(嵌套加锁,recursive_mutex 安全) ClearPendingRenewal(deviceID); return false; } @@ -616,6 +627,7 @@ void CLicenseDlg::OnLicenseRenewal() // 设置授权备注 bool SetLicenseRemark(const std::string& deviceID, const std::string& remark) { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); @@ -659,6 +671,7 @@ void CLicenseDlg::OnLicenseEditRemark() // 删除授权 bool DeleteLicense(const std::string& deviceID) { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); @@ -679,6 +692,10 @@ bool DeleteLicense(const std::string& deviceID) // 删除该 section (通过写入 NULL 删除整个 section) BOOL ret = ::WritePrivateProfileStringA(deviceID.c_str(), NULL, NULL, iniPath.c_str()); ::WritePrivateProfileStringA(NULL, NULL, NULL, iniPath.c_str()); // 刷新缓存 + + // 关键:清掉 UpdateLicenseActivity 的内存缓存。否则若同 SN 客户端再次连上来, + // cache 命中会跳过落盘 → disk 永远不会重建被删的 section。 + InvalidateLicenseActivityCache(deviceID); return ret != FALSE; } @@ -860,12 +877,17 @@ void CLicenseDlg::OnLicenseViewIPs() // 如果有记录被删除,保存更新后的 IP 列表 if (removedCount > 0) { - std::string iniPath = GetLicensesPath(); - config cfg(iniPath); - cfg.SetStr(lic.SerialNumber, "IP", newIPList); - lic.IP = newIPList; // 更新内存中的数据 + // 锁内只做 I/O —— UI 控件更新(SetItemText)放锁外,避免锁内触发 + // 任何可能的消息循环回调,保持锁占用时间最短 + { + std::lock_guard _lock(LicensesIniMutex()); + std::string iniPath = GetLicensesPath(); + config cfg(iniPath); + cfg.SetStr(lic.SerialNumber, "IP", newIPList); + } + lic.IP = newIPList; // 更新内存中的数据(与 m_Licenses 同步,不需要锁) - // 更新列表显示 + // 更新列表显示(UI 线程操作,必须在锁外) CString strIPDisplay = FormatIPDisplay(newIPList).c_str(); m_ListLicense.SetItemText(nItem, LIC_COL_IP, strIPDisplay); } @@ -985,6 +1007,9 @@ bool FindLicenseByIPAndMachine(const std::string& ip, const std::string& machine { if (ip.empty()) return false; + // 加锁保护整个 list 遍历,避免与并发的 SetStr(IP, ...) 交错读到中间态。 + // GetAllLicenses 内部也加锁,recursive_mutex 允许嵌套。 + std::lock_guard _lock(LicensesIniMutex()); auto licenses = GetAllLicenses(); for (const auto& lic : licenses) { if (lic.IP.empty()) continue; @@ -1167,6 +1192,7 @@ void CLicenseDlg::OnLicenseAutoFrp() FreeFrpPortAllocation(existingPort, lic.SerialNumber); // 仅当旧端口确实归属本 SN 时才释放 } { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); cfg.SetStr(lic.SerialNumber, "FrpConfig", frpConfig); @@ -1215,6 +1241,7 @@ void CLicenseDlg::OnLicenseRevokeFrp() // 清除 licenses.ini 中该授权的 FrpConfig 字段 { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); cfg.SetStr(lic.SerialNumber, "FrpConfig", ""); diff --git a/server/2015Remote/CPasswordDlg.cpp b/server/2015Remote/CPasswordDlg.cpp index 93f2ec7..5217d01 100644 --- a/server/2015Remote/CPasswordDlg.cpp +++ b/server/2015Remote/CPasswordDlg.cpp @@ -14,11 +14,65 @@ #include "InputDlg.h" #include "FrpsForSubDlg.h" #include "UIBranding.h" +#include +#include // 外部函数声明 extern std::vector splitString(const std::string& str, char delimiter); extern std::string GetFirstMasterIP(const std::string& master); +// ---- licenses.ini 并发与写抑制基础设施 (P1) ---- +// 见 CPasswordDlg.h 中 LicensesIniMutex() 注释。这里给出实例。 +std::recursive_mutex& LicensesIniMutex() +{ + static std::recursive_mutex m; + return m; +} + +namespace { + // UpdateLicenseActivity 的写抑制缓存:仅当字段实际变化或节流过期时才落盘。 + // + // ⚠️ Cache key 是 "SN|IP|machine" 三元组而非单 SN,因为同一 SN 可能被多个客户端 + // 共用(团购授权场景:上百台机器共用一个序列号)。若按 SN 索引,多客户端的 + // (IP, machine) 会在 cache 里反复互相覆盖 → ipChanged 几乎每次都为 true → + // 写抑制完全失效(实测从 0.6 次/秒只降到 0.7 次/秒)。 + // + // 5s 心跳 × 100 客户端,每客户端独立 30s 节流后 → 100/30 ≈ 3.3 次落盘/秒。 + // Passcode/HMAC 是 per-SN 的,按本结构会在每个客户端的 entry 里冗余存一份, + // 续期换码时所有客户端会各自触发一次重写(写入同一新值),冗余但无害。 + struct LicenseActivityCache { + time_t lastFlushTime = 0; // 上次实际落盘的 epoch 秒 + std::string lastPasscode; // 上次写入 ini 的 Passcode + std::string lastHMAC; // 上次写入 ini 的 HMAC + std::string lastLocation; // 上次写入 ini 的 Location + std::string lastIPWriteDate; // 上次写 IP 列表时的日期 yyMMdd + }; + // Key 格式:"SN|IP|machine"。IP/machine 可能为空(ctx == null 路径), + // 此时 key 形如 "SN||" —— 该路径自成一类节流域,互不干扰。 + std::unordered_map g_activityCache; + + // 30 秒节流窗口:LastActiveTime 最多 30 秒落盘一次(即便其它字段未变)。 + // UI 显示的"最后活跃"最多延迟 30 秒,业务可接受。 + constexpr int LAST_ACTIVE_THROTTLE_SECONDS = 30; +} + +// 由 DeleteLicense 等"在 cache 视野外修改了 disk"的路径调用,清掉某 SN 名下所有 +// (IP, machine) entry,强制下次 UpdateLicenseActivity 走 firstTime 路径重建 section。 +// Cache key 形如 "SN|IP|machine",同一 SN 可能对应多个 entry(多客户端共用授权), +// 必须按前缀遍历清除。 +void InvalidateLicenseActivityCache(const std::string& deviceID) +{ + std::lock_guard _lock(LicensesIniMutex()); + const std::string prefix = deviceID + "|"; + for (auto it = g_activityCache.begin(); it != g_activityCache.end(); ) { + if (it->first.compare(0, prefix.size(), prefix) == 0) { + it = g_activityCache.erase(it); + } else { + ++it; + } + } +} + // CPasswordDlg 对话框 IMPLEMENT_DYNAMIC(CPasswordDlg, CDialogEx) @@ -105,6 +159,7 @@ bool SaveLicenseInfo(const std::string& deviceID, const std::string& passcode, const std::string& authorization, const std::string& frpConfig) { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); @@ -146,6 +201,7 @@ bool SaveLicenseInfo(const std::string& deviceID, const std::string& passcode, bool LoadLicenseInfo(const std::string& deviceID, std::string& passcode, std::string& hmac, std::string& remark) { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); @@ -161,6 +217,7 @@ bool LoadLicenseInfo(const std::string& deviceID, std::string& passcode, // 加载授权的 FRP 配置 std::string LoadLicenseFrpConfig(const std::string& deviceID) { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); return cfg.GetStr(deviceID, "FrpConfig", ""); @@ -169,6 +226,7 @@ std::string LoadLicenseFrpConfig(const std::string& deviceID) // 加载授权的 Authorization(用于 V2 授权返回给第一层) std::string LoadLicenseAuthorization(const std::string& deviceID) { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); return cfg.GetStr(deviceID, "Authorization", ""); @@ -177,6 +235,7 @@ std::string LoadLicenseAuthorization(const std::string& deviceID) // 更新授权的 Authorization(V2 续期时更新) bool UpdateLicenseAuthorization(const std::string& deviceID, const std::string& authorization) { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); @@ -313,59 +372,115 @@ static int GetIPCount(const std::string& ipListStr) return (int)ipList.size(); } -// 更新授权活跃信息 +// 更新授权活跃信息(带写抑制) +// +// 设计要点:心跳每 5 秒触发一次本函数;同样的 SN 在稳态下绝大多数字段不会变化。 +// 朴素实现每次心跳都做 6-8 次 SetStr (整文件重写),5 秒就是一轮全文件 I/O 风暴, +// 100 在线时会饱和。本实现引入 in-memory 缓存 g_activityCache: +// - 字段未变化 + LastActiveTime 节流窗口(30 秒)内 → 直接 return,零 I/O +// - 字段变化(passcode/HMAC/IP/Location 任一)→ 仅写变化字段 +// - 节流过期 → 只写 LastActiveTime(轻量刷新) +// - IP 列表中的时间戳是日级精度(yyMMdd),跨天必须重写一次以刷新日期 +// +// 注意:仅在落盘成功后才更新 cache,保证 cache 永远反映"磁盘上当前值"。 bool UpdateLicenseActivity(const std::string& deviceID, const std::string& passcode, const std::string& hmac, const std::string& ip, const std::string& location, const std::string& machineName) { + std::lock_guard _lock(LicensesIniMutex()); + + // Cache key 是 (SN, IP, machine) 三元组 —— 同 SN 多客户端共用授权时各自独立节流。 + // 若同 SN 不同 (ip, machine) 共用一个 cache entry,IP 字段会在不同客户端间反复 + // 翻转,每次心跳都判定为 ipChanged → 写抑制完全失效。 + const std::string cacheKey = deviceID + "|" + ip + "|" + machineName; + auto& cache = g_activityCache[cacheKey]; + time_t now = time(nullptr); + + // 计算今日日期串(yyMMdd),用于和 IP 列表时间戳比对 + SYSTEMTIME st; + GetLocalTime(&st); + char today[8]; + sprintf_s(today, "%02d%02d%02d", st.wYear % 100, st.wMonth, st.wDay); + + // —— 决策阶段:判断本次心跳是否真的需要落盘 —— + // 注意:因 cacheKey 已经包含 (IP, machine),不同的客户端会落到不同 entry, + // 所以不再需要在字段比对中处理 IP/machine 变化 —— 那种"变化"其实是 cache miss。 + const bool firstTime = (cache.lastFlushTime == 0); + const bool passcodeChanged = (passcode != cache.lastPasscode); + const bool hmacChanged = (hmac != cache.lastHMAC); + const bool ipDayChanged = !ip.empty() && !cache.lastIPWriteDate.empty() && + std::string(today) != cache.lastIPWriteDate; + const bool locationChanged = !location.empty() && (location != cache.lastLocation); + const bool throttleExpired = (now - cache.lastFlushTime >= LAST_ACTIVE_THROTTLE_SECONDS); + + if (!firstTime && !passcodeChanged && !hmacChanged + && !ipDayChanged && !locationChanged && !throttleExpired) { + // 100% cache 命中:本客户端的所有字段都与上次落盘一致且节流未过期 + return true; + } + + // —— 落盘阶段:仅写真正需要写的字段 —— std::string iniPath = GetLicensesPath(); config cfg(iniPath); - // 检查该授权是否存在 + // 检查该授权是否存在(注意:此处仍需读磁盘,因为我们不缓存"是否存在"的事实) std::string existingPasscode = cfg.GetStr(deviceID, "Passcode", ""); - if (existingPasscode.empty()) { - // 授权不存在,但验证成功了,说明是既往授权,自动添加记录 + const bool isNewRecord = existingPasscode.empty(); + + if (isNewRecord) { + // 授权不存在但验证成功 —— 既往授权自动加入 cfg.SetStr(deviceID, "SerialNumber", deviceID); cfg.SetStr(deviceID, "Passcode", passcode); cfg.SetStr(deviceID, "HMAC", hmac); cfg.SetStr(deviceID, "Remark", "既往授权自动加入"); - cfg.SetStr(deviceID, "Status", LICENSE_STATUS_ACTIVE); // 新记录默认为有效 + cfg.SetStr(deviceID, "Status", LICENSE_STATUS_ACTIVE); } else { - // 授权已存在,更新 passcode(续期后 passcode 会变化) - cfg.SetStr(deviceID, "Passcode", passcode); - cfg.SetStr(deviceID, "HMAC", hmac); + // 已存在:只在 passcode/hmac 实际变化时才写(续期场景才会变) + if (firstTime || passcodeChanged) { + cfg.SetStr(deviceID, "Passcode", passcode); + } + if (firstTime || hmacChanged) { + cfg.SetStr(deviceID, "HMAC", hmac); + } } - // 更新最后活跃时间 - SYSTEMTIME st; - GetLocalTime(&st); + // LastActiveTime:走到这里就更新(节流过期或字段变化都需要刷新) char timeStr[32]; sprintf_s(timeStr, "%04d-%02d-%02d %02d:%02d:%02d", st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); cfg.SetStr(deviceID, "LastActiveTime", timeStr); - // 如果是新添加的记录,设置创建时间 - if (existingPasscode.empty()) { + if (isNewRecord) { cfg.SetStr(deviceID, "CreateTime", timeStr); } - // 更新 IP 列表(追加新 IP 或更新已有 IP 的时间戳) - // 格式: IP(机器名)|yyMMdd - if (!ip.empty()) { + // IP 列表:本客户端首次 或 同客户端跨天 才重写(UpdateIPList 会在 disk 上合并) + if (!ip.empty() && (firstTime || ipDayChanged)) { std::string existingIPList = cfg.GetStr(deviceID, "IP", ""); std::string newIPList = UpdateIPList(existingIPList, ip, machineName); cfg.SetStr(deviceID, "IP", newIPList); + cache.lastIPWriteDate = today; } - if (!location.empty()) { + + if (!location.empty() && (firstTime || locationChanged)) { cfg.SetStr(deviceID, "Location", location); } + // —— 同步缓存(必须在落盘成功后)—— + cache.lastFlushTime = now; + cache.lastPasscode = passcode; + cache.lastHMAC = hmac; + if (!location.empty()) { + cache.lastLocation = location; + } + return true; } // 检查授权是否已被撤销 bool IsLicenseRevoked(const std::string& deviceID) { + std::lock_guard _lock(LicensesIniMutex()); std::string iniPath = GetLicensesPath(); config cfg(iniPath); std::string status = cfg.GetStr(deviceID, "Status", LICENSE_STATUS_ACTIVE); diff --git a/server/2015Remote/CPasswordDlg.h b/server/2015Remote/CPasswordDlg.h index abcb4f8..ada2fd8 100644 --- a/server/2015Remote/CPasswordDlg.h +++ b/server/2015Remote/CPasswordDlg.h @@ -2,10 +2,23 @@ #include #include +#include #include "Resource.h" #include "common/commands.h" #include "LangManager.h" +// 全局 licenses.ini 互斥锁(Meyers singleton,跨翻译单元共享)。 +// 所有读写 licenses.ini 的函数入口必须加锁,否则在心跳并发下会出现 +// read-modify-write 丢更新(典型受害者:PendingQuota / IP 列表)。 +// 使用 recursive_mutex 是因为部分函数会嵌套调用(如 DecrementPendingQuota → ClearPendingRenewal)。 +std::recursive_mutex& LicensesIniMutex(); + +// 让 UpdateLicenseActivity 内部缓存里某个 SN 的 entry 失效。 +// 必须在外部修改了授权(删除 / 重新创建 section)后调用,否则 cache 命中策略 +// 会跳过本应触发的"既往授权自动加入"路径,导致 disk 上的 section 不会重建。 +// 实现在 CPasswordDlg.cpp,需持 LicensesIniMutex(内部会自行加锁,可在已加锁线程嵌套调用)。 +void InvalidateLicenseActivityCache(const std::string& deviceID); + // CPasswordDlg 对话框 namespace TcpClient { std::string ObfuscateAuthorization(const std::string& auth);