Improve: client/server - stable client ID via MachineGuid+path (V2)

This commit is contained in:
yuanyuanxiang
2026-05-06 21:32:06 +02:00
parent 0aa75882d1
commit 2c5b5ad628
5 changed files with 282 additions and 30 deletions

View File

@@ -225,7 +225,10 @@ std::string GetCurrentUserNameA()
#define XXH_INLINE_ALL #define XXH_INLINE_ALL
#include "common/xxhash.h" #include "common/xxhash.h"
// 基于客户端信息计算唯一ID: { IP, PC, OS, CPU, PATH }
// 老算法基于客户端信息计算唯一ID: { IP, PC, OS, CPU, PATH }
// 注意pubIP 不稳定DHCP/换网络)会让 ID 跳变;同 hostname+同安装路径的多机会撞库。
// 保留此函数仅为协议兼容(老服务端仍按这个算法验算 RES_CLIENT_ID
uint64_t CalcalateID(const std::vector<std::string>& clientInfo) uint64_t CalcalateID(const std::vector<std::string>& clientInfo)
{ {
std::string s; std::string s;
@@ -236,6 +239,52 @@ uint64_t CalcalateID(const std::vector<std::string>& clientInfo)
return XXH64(s.c_str(), s.length(), 0); return XXH64(s.c_str(), s.length(), 0);
} }
// 读取 Windows 安装时生成的机器 GUID。
// HKLM\Software\Microsoft\Cryptography\MachineGuid 是 Windows 安装时生成的随机 GUID
// 重装系统才会变局域网每台机器都不同即便同镜像sysprep 也会重置)。
// 这是比 pubIP/PCName/CPU 都更稳定且更具区分度的硬件标识。
static std::string GetMachineGuidWindows()
{
HKEY hKey = NULL;
// KEY_WOW64_64KEY: 32 位进程也访问 64 位注册表视图,避免 WOW6432Node 重定向。
if (RegOpenKeyExA(HKEY_LOCAL_MACHINE,
"SOFTWARE\\Microsoft\\Cryptography",
0, KEY_READ | KEY_WOW64_64KEY, &hKey) != ERROR_SUCCESS) {
return std::string();
}
char buf[64] = {};
DWORD sz = sizeof(buf) - 1; // 留 1 字节给 NUL
DWORD type = 0;
LSTATUS s = RegQueryValueExA(hKey, "MachineGuid", NULL, &type,
(BYTE*)buf, &sz);
RegCloseKey(hKey);
if (s != ERROR_SUCCESS || type != REG_SZ) return std::string();
return std::string(buf);
}
// 路径归一化:先尝试展开成长路径(如 PROGRA~1 -> Program Files再小写化。
// 用于 V2 ID 的输入,保证大小写或长短名变化时同一可执行文件得到同一 ID。
static std::string NormalizeExePathLower(const char* path)
{
char longPath[MAX_PATH] = {};
if (GetLongPathNameA(path, longPath, MAX_PATH) == 0) {
// 展开失败(路径不存在等罕见情况):直接用原值
strcpy_s(longPath, path);
}
CharLowerA(longPath); // 原地小写化(对 ASCII 简单,对中文路径会按宽字符规则处理)
return std::string(longPath);
}
// 新算法machineGuid + 归一化路径
// - 同机同程序:永远同 ID不依赖 IP/PCName/OS/CPU
// - 局域网多机相同镜像MachineGuid 必不同 → ID 必不同。
// - 一台机两份程序在不同目录 → ID 不同。
uint64_t CalcalateIDv2(const std::string& machineGuid, const std::string& normalizedPath)
{
std::string s = machineGuid + "|" + normalizedPath;
return XXH64(s.c_str(), s.length(), 0);
}
BOOL IsAuthKernel(std::string &str) { BOOL IsAuthKernel(std::string &str) {
BOOL isAuthKernel = FALSE; BOOL isAuthKernel = FALSE;
std::string pid = std::to_string(GetCurrentProcessId()); std::string pid = std::to_string(GetCurrentProcessId());
@@ -332,7 +381,17 @@ LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string
LoginInfor.AddReserved(IsRunningAsAdmin()); LoginInfor.AddReserved(IsRunningAsAdmin());
char cpuInfo[32]; char cpuInfo[32];
sprintf(cpuInfo, "%dMHz", dwCPUMHz); sprintf(cpuInfo, "%dMHz", dwCPUMHz);
// V2 ID 算法MachineGuid + 归一化路径
// - 同机同程序路径永远同 ID不依赖 IP/PCName/OS/CPU 漂移)
// - 局域网多机即便同镜像sysprep 会让 MachineGuid 各不同)也不撞库
// MachineGuid 读取失败的极端情况退化到老算法,保兼容。
std::string machineGuid = GetMachineGuidWindows();
if (!machineGuid.empty()) {
conn.clientID = CalcalateIDv2(machineGuid, NormalizeExePathLower(buf));
} else {
Mprintf("WARN: MachineGuid 读取失败,回退到老 ID 算法\n");
conn.clientID = CalcalateID({ pubIP, szPCName, LoginInfor.OsVerInfoEx, cpuInfo, buf }); conn.clientID = CalcalateID({ pubIP, szPCName, LoginInfor.OsVerInfoEx, cpuInfo, buf });
}
auto clientID = std::to_string(conn.clientID); auto clientID = std::to_string(conn.clientID);
Mprintf("此客户端的唯一标识为: %s\n", clientID.c_str()); Mprintf("此客户端的唯一标识为: %s\n", clientID.c_str());
char reservedInfo[64]; char reservedInfo[64];

View File

@@ -690,6 +690,49 @@ std::string getUsername()
return u ? u : "?"; return u ? u : "?";
} }
// 读取 systemd / dbus 维护的 machine-id与 Windows MachineGuid 等价)
// /etc/machine-id 在系统首次启动时生成的随机 32 字符 hex GUID。
// 对应 Windows: HKLM\Software\Microsoft\Cryptography\MachineGuid。
// 重装系统才会变;同一镜像 dd 出来的多机会撞——但规范的批量部署
// 工具 (cloud-init / kickstart) 会重置它。
static std::string getMachineId()
{
// 优先 /etc/machine-id某些精简系统可能放在 /var/lib/dbus/machine-id
const char* paths[] = { "/etc/machine-id", "/var/lib/dbus/machine-id" };
for (const char* p : paths) {
std::ifstream f(p);
if (!f.is_open()) continue;
std::string id;
std::getline(f, id);
// 去掉尾部空白和换行
while (!id.empty() && (id.back() == '\n' || id.back() == '\r' ||
id.back() == ' ' || id.back() == '\t')) {
id.pop_back();
}
if (!id.empty()) return id;
}
return std::string();
}
// 路径归一化Linux 版):解析符号链接 + 转小写
// realpath 等价于 Windows 的 GetLongPathName把 /usr/local/bin/../foo 这种
// 折回到规范形式;小写化避免大小写差异引起 ID 不同Linux 文件系统本身大小写
// 敏感,但保持与 Windows V2 算法一致的归一化策略,跨端一致性优先)。
static std::string normalizeExePathLower(const std::string& path)
{
char resolved[PATH_MAX] = {};
std::string out;
if (realpath(path.c_str(), resolved) != nullptr) {
out = resolved;
} else {
out = path; // 解析失败(罕见):用原值
}
for (auto& c : out) {
if (c >= 'A' && c <= 'Z') c = c - 'A' + 'a';
}
return out;
}
// 获取屏幕分辨率字符串(格式 "显示器数:宽*高" // 获取屏幕分辨率字符串(格式 "显示器数:宽*高"
std::string getScreenResolution() std::string getScreenResolution()
{ {
@@ -1010,7 +1053,19 @@ int main(int argc, char* argv[])
logInfo.AddReserved(getUsername().c_str()); // [13] RES_USERNAME logInfo.AddReserved(getUsername().c_str()); // [13] RES_USERNAME
logInfo.AddReserved(getuid() == 0 ? 1 : 0); // [14] RES_ISADMIN logInfo.AddReserved(getuid() == 0 ? 1 : 0); // [14] RES_ISADMIN
logInfo.AddReserved(getScreenResolution().c_str()); // [15] RES_RESOLUTION logInfo.AddReserved(getScreenResolution().c_str()); // [15] RES_RESOLUTION
// 计算客户端 ID与服务端 CONTEXT_OBJECT::CalculateID 相同算法) // V2 ID 算法machine-id + 归一化路径
// - 同机同程序路径永远同 ID不依赖 IP/hostname/distro/CPU 漂移)
// - 局域网多机即便同镜像cloud-init/kickstart 会让 machine-id 各不同
// - machine-id 读取失败时退化到老算法pubIP|hostname|distro|cpu|path保兼容
std::string machineId = getMachineId();
if (!machineId.empty()) {
std::string normPath = normalizeExePathLower(exePath);
std::string idInput = machineId + "|" + normPath;
g_myClientID = XXH64(idInput.c_str(), idInput.length(), 0);
Mprintf("Calculated clientID(v2): %llu (machineId=%s, path=%s)\n",
g_myClientID, machineId.c_str(), normPath.c_str());
} else {
// 老算法兜底(与服务端 CONTEXT_OBJECT::CalculateID 相同算法)
// 格式: pubIP|hostname|os|cpu|path // 格式: pubIP|hostname|os|cpu|path
char cpuStr[32]; char cpuStr[32];
snprintf(cpuStr, sizeof(cpuStr), "%uMHz", logInfo.dwCPUMHz); snprintf(cpuStr, sizeof(cpuStr), "%uMHz", logInfo.dwCPUMHz);
@@ -1020,7 +1075,8 @@ int main(int argc, char* argv[])
cpuStr + "|" + cpuStr + "|" +
exePath; exePath;
g_myClientID = XXH64(idInput.c_str(), idInput.length(), 0); g_myClientID = XXH64(idInput.c_str(), idInput.length(), 0);
Mprintf("Calculated clientID: %llu (from: %s)\n", g_myClientID, idInput.c_str()); Mprintf("Calculated clientID(v1 fallback): %llu (machine-id 读取失败)\n", g_myClientID);
}
logInfo.AddReserved(std::to_string(g_myClientID).c_str()); // [16] RES_CLIENT_ID logInfo.AddReserved(std::to_string(g_myClientID).c_str()); // [16] RES_CLIENT_ID
logInfo.AddReserved((int)getpid()); // [17] RES_PID logInfo.AddReserved((int)getpid()); // [17] RES_PID

View File

@@ -214,6 +214,54 @@ static std::string getUsername()
return user ? std::string(user) : "unknown"; return user ? std::string(user) : "unknown";
} }
// 读取 IOKit 维护的 IOPlatformUUID与 Windows MachineGuid 等价)
// 这是主板/系统级 UUID由 IOPlatformExpertDevice 服务提供,重装系统通常不变。
// 对应Windows HKLM\Software\Microsoft\Cryptography\MachineGuid
// Linux /etc/machine-id
static std::string getMachineId()
{
std::string result;
io_service_t platformExpert = IOServiceGetMatchingService(
kIOMasterPortDefault,
IOServiceMatching("IOPlatformExpertDevice"));
if (platformExpert != IO_OBJECT_NULL) {
CFTypeRef uuidProperty = IORegistryEntryCreateCFProperty(
platformExpert, CFSTR(kIOPlatformUUIDKey),
kCFAllocatorDefault, 0);
if (uuidProperty != nullptr) {
if (CFGetTypeID(uuidProperty) == CFStringGetTypeID()) {
CFStringRef uuidStr = (CFStringRef)uuidProperty;
char buf[64] = {};
if (CFStringGetCString(uuidStr, buf, sizeof(buf), kCFStringEncodingUTF8)) {
result = buf;
}
}
CFRelease(uuidProperty);
}
IOObjectRelease(platformExpert);
}
return result;
}
// 路径归一化macOS 版):解析符号链接 + 转小写
// realpath 把 /Applications/foo/../bar 之类折回规范形式;
// 小写化保持与 Windows/Linux 跨端一致。macOS HFS+/APFS 默认大小写不敏感,
// 转小写不改变文件标识、但避免路径串大小写差异引起 ID 不同。
static std::string normalizeExePathLower(const std::string& path)
{
char resolved[PATH_MAX] = {};
std::string out;
if (realpath(path.c_str(), resolved) != nullptr) {
out = resolved;
} else {
out = path; // 解析失败:用原值
}
for (auto& c : out) {
if (c >= 'A' && c <= 'Z') c = c - 'A' + 'a';
}
return out;
}
// Get screen resolution // Get screen resolution
static std::string getScreenResolution() static std::string getScreenResolution()
{ {
@@ -552,8 +600,20 @@ static void fillLoginInfo(LOGIN_INFOR& info)
std::string resolution = getScreenResolution(); std::string resolution = getScreenResolution();
info.AddReserved(resolution.c_str()); info.AddReserved(resolution.c_str());
// 17. Client ID (calculated from system info, same algorithm as server) // 17. Client ID
// Format: pubIP|hostname|os|cpu|path // V2 算法IOPlatformUUID + 归一化路径
// - 同机同程序路径永远同 ID不依赖 IP/hostname/os/CPU 漂移)
// - IOPlatformUUID 主板级,重装系统通常不变;多机各不相同
// - 读取失败时退化到老算法pubIP|hostname|os|cpu|path保兼容
std::string machineId = getMachineId();
if (!machineId.empty()) {
std::string normPath = normalizeExePathLower(exePath);
std::string idInput = machineId + "|" + normPath;
g_myClientID = XXH64(idInput.c_str(), idInput.length(), 0);
NSLog(@"ClientID(v2): %llu (machineId=%s, path=%s)",
g_myClientID, machineId.c_str(), normPath.c_str());
} else {
// 老算法兜底
char cpuStr[32]; char cpuStr[32];
snprintf(cpuStr, sizeof(cpuStr), "%uMHz", info.dwCPUMHz); snprintf(cpuStr, sizeof(cpuStr), "%uMHz", info.dwCPUMHz);
std::string idInput = (pubIP.empty() ? "?" : pubIP) + "|" + std::string idInput = (pubIP.empty() ? "?" : pubIP) + "|" +
@@ -562,6 +622,8 @@ static void fillLoginInfo(LOGIN_INFOR& info)
cpuStr + "|" + cpuStr + "|" +
exePath; exePath;
g_myClientID = XXH64(idInput.c_str(), idInput.length(), 0); g_myClientID = XXH64(idInput.c_str(), idInput.length(), 0);
NSLog(@"ClientID(v1 fallback): %llu (IOPlatformUUID 读取失败)", g_myClientID);
}
info.AddReserved(std::to_string(g_myClientID).c_str()); info.AddReserved(std::to_string(g_myClientID).c_str());
NSLog(@"LOGIN_INFOR filled: OS=%s, Host=%s, CPU=%dMHz, PubIP=%s, ClientID=%llu", NSLog(@"LOGIN_INFOR filled: OS=%s, Host=%s, CPU=%dMHz, PubIP=%s, ClientID=%llu",

View File

@@ -1342,15 +1342,25 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
verDisplay, install, startTime, v[RES_CLIENT_TYPE].empty() ? "?" : v[RES_CLIENT_TYPE].c_str(), path, verDisplay, install, startTime, v[RES_CLIENT_TYPE].empty() ? "?" : v[RES_CLIENT_TYPE].c_str(), path,
v[RES_CLIENT_PUBIP].empty() ? strIP : v[RES_CLIENT_PUBIP].c_str(), startTime, capStr, v[RES_CLIENT_PUBIP].empty() ? strIP : v[RES_CLIENT_PUBIP].c_str(), startTime, capStr,
}; };
auto id = CONTEXT_OBJECT::CalculateID(data); // 优先采用客户端自报的 ID新客户端用 V2 算法 = MachineGuid + 归一化路径,
auto id_str = std::to_string(id); // 比服务端按老算法 IP+PC+OS+CPU+PATH 重算更稳定)。
if (v[RES_CLIENT_ID].empty()) { // 客户端未发或解析失败时,回退到服务端老算法重算(兼容老客户端)。
v[RES_CLIENT_ID] = id_str; auto computedId = CONTEXT_OBJECT::CalculateID(data);
} else if (id_str != v[RES_CLIENT_ID]) { uint64_t id = 0;
Mprintf("上线消息 - 主机ID错误: calc=%llu, recv=%s, IP=%s, Path=%s\n", if (!v[RES_CLIENT_ID].empty()) {
id, v[RES_CLIENT_ID].c_str(), strIP.GetString(), path.GetString()); id = std::strtoull(v[RES_CLIENT_ID].c_str(), nullptr, 10);
} }
if (id == 0) {
id = computedId;
v[RES_CLIENT_ID] = std::to_string(id);
}
bool modify = false, needConvert = true; bool modify = false, needConvert = true;
// 新客户端 V2 ID 算法首次上线时,把老 ID 下的元数据迁过来。
if (TryMigrateClientMetadata(id, strPCName, path)) {
modify = true;
}
CString loc = m_ClientMap->GetClientMapData(id, MAP_LOCATION); CString loc = m_ClientMap->GetClientMapData(id, MAP_LOCATION);
if (loc.IsEmpty()) { if (loc.IsEmpty()) {
loc = v[RES_CLIENT_LOC].c_str(); loc = v[RES_CLIENT_LOC].c_str();
@@ -5612,6 +5622,65 @@ LRESULT CMy2015RemoteDlg::OnUserToOnlineList(WPARAM wParam, LPARAM lParam)
} }
// 启发式 ID 迁移:当新客户端 V2 算法生成的 ID 在 m_ClientMap 里没条目时,
// 按 (ComputerName, ProgramPath) 扫老条目找唯一匹配,把元数据搬过去。
// 设计取舍见相关讨论:
// - 严格 ComputerName 相等 + 大小写不敏感 ProgramPath 匹配。
// - 多候选保守跳过——避免局域网同 hostname+同路径多机互相串备注。
// - 老条目不删,作为审计/回滚依据dat 文件不会显著膨胀。
//
// 线程安全说明:
// - AddList 由 SendMessage(WM_USERTOONLINELIST) 进入,跑在 UI 线程,串行。
// - newId 在本函数返回前没进入 m_HostListUI 上看不到这台机器,
// 用户无法通过右键触发 OnOnlineHostnote 写 newId 的备注。
// - IO 线程的 AUTH 写发生在登录认证流之后,针对的是已注册客户端,不会
// 在 newId 首次出现的瞬间踩进来。
// - GetAll() 返回快照副本,迭代期间老条目被并发修改不影响匹配(我们只
// 看 ComputerName/ProgramPath这两个字段不会被并发改写
// - _ClientList 实现内部有同步(项目里其它路径同样不持 m_cs 调用它)。
// 故无需额外加锁。
bool CMy2015RemoteDlg::TryMigrateClientMetadata(uint64_t newId, const CString& pcName, const CString& exePath)
{
if (!m_ClientMap) return false;
if (m_ClientMap->Exists(newId)) return false; // 已有条目,无需迁移
const std::string targetPC = pcName.GetString();
const std::string targetPath = exePath.GetString();
// 扫描所有条目,找匹配的老 ID多于一个就停按歧义处理
std::vector<ClientKey> candidates;
for (const auto& kv : m_ClientMap->GetAll()) {
if (kv.first == newId) continue;
if (strcmp(kv.second.ComputerName, targetPC.c_str()) == 0 &&
_stricmp(kv.second.ProgramPath, targetPath.c_str()) == 0) {
candidates.push_back(kv.first);
if (candidates.size() > 1) break;
}
}
if (candidates.size() > 1) {
Mprintf("ID 迁移歧义: PC=%s, Path=%s 命中 %zu+ 个候选,保守跳过——"
"需要运维手动确认是哪台机器的元数据\n",
targetPC.c_str(), targetPath.c_str(), candidates.size());
return false;
}
if (candidates.empty()) return false; // 真正的新机器
// 唯一匹配:复制用户可设置的元数据到新 ID其它字段下次心跳/上线会覆盖)
ClientKey oldId = candidates[0];
m_ClientMap->SetClientMapData(newId, MAP_NOTE,
m_ClientMap->GetClientMapData(oldId, MAP_NOTE).GetString());
m_ClientMap->SetClientMapData(newId, MAP_LOCATION,
m_ClientMap->GetClientMapData(oldId, MAP_LOCATION).GetString());
m_ClientMap->SetClientMapInteger(newId, MAP_LEVEL,
m_ClientMap->GetClientMapInteger(oldId, MAP_LEVEL));
m_ClientMap->SetClientMapInteger(newId, MAP_AUTH,
m_ClientMap->GetClientMapInteger(oldId, MAP_AUTH));
Mprintf("ID 迁移: %llu -> %llu (PC=%s, Path=%s)\n",
oldId, newId, targetPC.c_str(), targetPath.c_str());
return true;
}
// 根据列表显示索引获取 context考虑分组过滤 // 根据列表显示索引获取 context考虑分组过滤
context* CMy2015RemoteDlg::GetContextByListIndex(int iItem) context* CMy2015RemoteDlg::GetContextByListIndex(int iItem)
{ {

View File

@@ -245,6 +245,12 @@ public:
std::string m_v2KeyPath; // V2 密钥文件路径 std::string m_v2KeyPath; // V2 密钥文件路径
void RebuildFilteredIndices(); // 重建过滤索引 void RebuildFilteredIndices(); // 重建过滤索引
context* GetContextByListIndex(int iItem); // 根据列表索引获取 context考虑分组过滤 context* GetContextByListIndex(int iItem); // 根据列表索引获取 context考虑分组过滤
// 启发式 ID 迁移:新客户端首次上线时,按 (ComputerName, ProgramPath) 在 m_ClientMap
// 里找老条目,若唯一匹配则把元数据(备注/位置/级别/授权)拷贝到 newId。
// 多于一个候选时保守跳过,写日志让运维手动处理,避免误聚合。
// 返回 true 表示有迁移发生(调用方需触发 dat 文件落盘)。
bool TryMigrateClientMetadata(uint64_t newId, const CString& pcName, const CString& exePath);
void LoadListData(const std::string& group); void LoadListData(const std::string& group);
void DeletePopupWindow(BOOL bForce = FALSE); void DeletePopupWindow(BOOL bForce = FALSE);
void CheckHeartbeat(); void CheckHeartbeat();