Refactor telemetry client registration and heartbeat

Replaces the UUID registration and heartbeat logic with a new two-step RSA-based registration process. Adds cryptographic signing of heartbeat requests using a client private key, and updates local storage keys to 'mofox_uuid' and 'mofox_private_key'. Improves error handling and response to server-side authentication failures, and updates request headers and payloads to match the new protocol.
This commit is contained in:
雅诺狐
2025-09-13 23:21:17 +08:00
parent 95fc44e067
commit 06a6c71775

View File

@@ -1,9 +1,13 @@
import asyncio import asyncio
import base64
import json
import aiohttp import aiohttp
import platform import platform
from datetime import datetime, timezone from datetime import datetime, timezone
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from src.common.logger import get_logger from src.common.logger import get_logger
from src.common.tcp_connector import get_tcp_connector from src.common.tcp_connector import get_tcp_connector
from src.config.config import global_config from src.config.config import global_config
@@ -24,9 +28,12 @@ class TelemetryHeartBeatTask(AsyncTask):
self.server_url = TELEMETRY_SERVER_URL self.server_url = TELEMETRY_SERVER_URL
"""遥测服务地址""" """遥测服务地址"""
self.client_uuid: str | None = local_storage["mmc_uuid"] if "mmc_uuid" in local_storage else None # type: ignore self.client_uuid: str | None = local_storage["mofox_uuid"] if "mofox_uuid" in local_storage else None # type: ignore
"""客户端UUID""" """客户端UUID"""
self.private_key_pem: str | None = local_storage["mofox_private_key"] if "mofox_private_key" in local_storage else None # type: ignore
"""客户端私钥"""
self.info_dict = self._get_sys_info() self.info_dict = self._get_sys_info()
"""系统信息字典""" """系统信息字典"""
@@ -36,7 +43,7 @@ class TelemetryHeartBeatTask(AsyncTask):
info_dict = { info_dict = {
"os_type": "Unknown", "os_type": "Unknown",
"py_version": platform.python_version(), "py_version": platform.python_version(),
"mmc_version": global_config.MMC_VERSION, "mofox_version": global_config.MMC_VERSION,
} }
match platform.system(): match platform.system():
@@ -51,77 +58,224 @@ class TelemetryHeartBeatTask(AsyncTask):
return info_dict return info_dict
def _generate_signature(self, request_body: dict) -> tuple[str, str]:
"""
生成RSA签名
Returns:
tuple[str, str]: (timestamp, signature_b64)
"""
if not self.private_key_pem:
raise ValueError("私钥未初始化")
# 生成时间戳
timestamp = datetime.now(timezone.utc).isoformat()
# 创建签名数据字符串
sign_data = f"{self.client_uuid}:{timestamp}:{json.dumps(request_body, separators=(',', ':'))}"
# 加载私钥
private_key = serialization.load_pem_private_key(
self.private_key_pem.encode('utf-8'),
password=None
)
# 确保是RSA私钥
if not isinstance(private_key, rsa.RSAPrivateKey):
raise ValueError("私钥必须是RSA格式")
# 生成签名
signature = private_key.sign(
sign_data.encode('utf-8'),
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
# Base64编码
signature_b64 = base64.b64encode(signature).decode('utf-8')
return timestamp, signature_b64
def _decrypt_challenge(self, challenge_b64: str) -> str:
"""
解密挑战数据
Args:
challenge_b64: Base64编码的挑战数据
Returns:
str: 解密后的UUID字符串
"""
if not self.private_key_pem:
raise ValueError("私钥未初始化")
# 加载私钥
private_key = serialization.load_pem_private_key(
self.private_key_pem.encode('utf-8'),
password=None
)
# 确保是RSA私钥
if not isinstance(private_key, rsa.RSAPrivateKey):
raise ValueError("私钥必须是RSA格式")
# 解密挑战数据
decrypted_bytes = private_key.decrypt(
base64.b64decode(challenge_b64),
padding.OAEP(
mgf=padding.MGF1(hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
return decrypted_bytes.decode('utf-8')
async def _req_uuid(self) -> bool: async def _req_uuid(self) -> bool:
""" """
向服务端请求UUID不应在已存在UUID的情况下调用会覆盖原有的UUID 向服务端请求UUID和私钥(两步注册流程
""" """
if "deploy_time" not in local_storage:
logger.error("本地存储中缺少部署时间无法请求UUID")
return False
try_count: int = 0 try_count: int = 0
while True: while True:
# 如果不存在则向服务端请求一个新的UUID注册客户端 logger.info("正在向遥测服务端注册客户端...")
logger.info("正在向遥测服务端请求UUID...")
try: try:
async with aiohttp.ClientSession(connector=await get_tcp_connector()) as session: async with aiohttp.ClientSession(connector=await get_tcp_connector()) as session:
# Step 1: 获取临时UUID、私钥和挑战数据
logger.debug("开始注册步骤1获取临时UUID和私钥")
async with session.post( async with session.post(
f"{TELEMETRY_SERVER_URL}/stat/reg_client", f"{TELEMETRY_SERVER_URL}/stat/reg_client_step1",
json={"deploy_time": datetime.fromtimestamp(local_storage["deploy_time"], tz=timezone.utc).isoformat()}, json={},
timeout=aiohttp.ClientTimeout(total=5), # 设置超时时间为5秒 timeout=aiohttp.ClientTimeout(total=5),
) as response: ) as response:
logger.debug(f"{TELEMETRY_SERVER_URL}/stat/reg_client") logger.debug(f"Step1 Response status: {response.status}")
logger.debug(local_storage["deploy_time"]) # type: ignore
logger.debug(f"Response status: {response.status}") if response.status != 200:
response_text = await response.text()
logger.error(
f"注册步骤1失败状态码: {response.status}, 响应内容: {response_text}"
)
raise aiohttp.ClientResponseError(
request_info=response.request_info,
history=response.history,
status=response.status,
message=f"Step1 failed: {response_text}"
)
step1_data = await response.json()
temp_uuid = step1_data.get("temp_uuid")
private_key = step1_data.get("private_key")
challenge = step1_data.get("challenge")
if not all([temp_uuid, private_key, challenge]):
logger.error("Step1响应缺少必要字段temp_uuid, private_key 或 challenge")
raise ValueError("Step1响应数据不完整")
# 临时保存私钥用于解密
self.private_key_pem = private_key
# 解密挑战数据
logger.debug("解密挑战数据...")
try:
decrypted_uuid = self._decrypt_challenge(challenge)
except Exception as e:
logger.error(f"解密挑战数据失败: {e}")
raise
# 验证解密结果
if decrypted_uuid != temp_uuid:
logger.error(f"解密结果验证失败: 期望 {temp_uuid}, 实际 {decrypted_uuid}")
raise ValueError("解密结果与临时UUID不匹配")
logger.debug("挑战数据解密成功开始注册步骤2")
# Step 2: 发送解密结果完成注册
async with session.post(
f"{TELEMETRY_SERVER_URL}/stat/reg_client_step2",
json={
"temp_uuid": temp_uuid,
"decrypted_uuid": decrypted_uuid
},
timeout=aiohttp.ClientTimeout(total=5),
) as response:
logger.debug(f"Step2 Response status: {response.status}")
if response.status == 200: if response.status == 200:
data = await response.json() step2_data = await response.json()
if client_id := data.get("mmc_uuid"): mofox_uuid = step2_data.get("mofox_uuid")
# 将UUID存储到本地
local_storage["mmc_uuid"] = client_id if mofox_uuid:
self.client_uuid = client_id # 将正式UUID和私钥存储到本地
logger.info(f"成功获取UUID: {self.client_uuid}") local_storage["mofox_uuid"] = mofox_uuid
return True # 成功获取UUID返回True local_storage["mofox_private_key"] = private_key
self.client_uuid = mofox_uuid
self.private_key_pem = private_key
logger.info(f"成功注册客户端UUID: {self.client_uuid}")
return True
else: else:
logger.error("无效的服务端响应") logger.error("Step2响应缺少mofox_uuid字段")
raise ValueError("Step2响应数据不完整")
elif response.status in [400, 401]:
# 临时数据无效,需要重新开始
response_text = await response.text()
logger.warning(f"Step2失败临时数据无效: {response.status}, {response_text}")
raise ValueError(f"Step2失败: {response_text}")
else: else:
response_text = await response.text() response_text = await response.text()
logger.error( logger.error(
f"请求UUID失败不过你还是可以正常使用墨狐,状态码: {response.status}, 响应内容: {response_text}" f"注册步骤2失败,状态码: {response.status}, 响应内容: {response_text}"
) )
raise aiohttp.ClientResponseError(
request_info=response.request_info,
history=response.history,
status=response.status,
message=f"Step2 failed: {response_text}"
)
except Exception as e: except Exception as e:
import traceback import traceback
error_msg = str(e) or "未知错误" error_msg = str(e) or "未知错误"
logger.warning( logger.warning(
f"请求UUID出错,不过你还是可以正常使用墨狐: {type(e).__name__}: {error_msg}" f"注册客户端出错,不过你还是可以正常使用墨狐: {type(e).__name__}: {error_msg}"
) # 可能是网络问题 )
logger.debug(f"完整错误信息: {traceback.format_exc()}") logger.debug(f"完整错误信息: {traceback.format_exc()}")
# 请求失败,重试次数+1 # 请求失败,重试次数+1
try_count += 1 try_count += 1
if try_count > 3: if try_count > 3:
# 如果超过3次仍然失败则退出 # 如果超过3次仍然失败则退出
logger.error("获取UUID失败,请检查网络连接或服务端状态") logger.error("注册客户端失败,请检查网络连接或服务端状态")
return False return False
else: else:
# 如果可以重试,等待后继续(指数退避) # 如果可以重试,等待后继续(指数退避)
logger.info(f"获取UUID失败,将于 {4**try_count} 秒后重试...") logger.info(f"注册客户端失败,将于 {4**try_count} 秒后重试...")
await asyncio.sleep(4**try_count) await asyncio.sleep(4**try_count)
async def _send_heartbeat(self): async def _send_heartbeat(self):
"""向服务器发送心跳""" """向服务器发送心跳"""
headers = { if not self.client_uuid or not self.private_key_pem:
"Client-UUID": self.client_uuid, logger.error("UUID或私钥未初始化无法发送心跳")
"User-Agent": f"HeartbeatClient/{self.client_uuid[:8]}", # type: ignore return
}
logger.debug(f"正在发送心跳到服务器: {self.server_url}")
logger.debug(str(headers))
try: try:
# 生成签名
timestamp, signature = self._generate_signature(self.info_dict)
headers = {
"X-mofox-UUID": self.client_uuid,
"X-mofox-Signature": signature,
"X-mofox-Timestamp": timestamp,
"User-Agent": f"MofoxClient/{self.client_uuid[:8]}",
"Content-Type": "application/json"
}
logger.debug(f"正在发送心跳到服务器: {self.server_url}")
logger.debug(f"Headers: {headers}")
async with aiohttp.ClientSession(connector=await get_tcp_connector()) as session: async with aiohttp.ClientSession(connector=await get_tcp_connector()) as session:
async with session.post( async with session.post(
f"{self.server_url}/stat/client_heartbeat", f"{self.server_url}/stat/client_heartbeat",
@@ -135,31 +289,62 @@ class TelemetryHeartBeatTask(AsyncTask):
if 200 <= response.status < 300: if 200 <= response.status < 300:
# 成功 # 成功
logger.debug(f"心跳发送成功,状态码: {response.status}") logger.debug(f"心跳发送成功,状态码: {response.status}")
elif response.status == 403: elif response.status == 401:
# 403 Forbidden # 401 Unauthorized - 签名验证失败
logger.warning( logger.warning(
"此消息不会影响正常使用心跳发送失败403 Forbidden: 可能是UUID无效或未注册" "此消息不会影响正常使用心跳发送失败401 Unauthorized: 签名验证失败"
"处理措施:重置UUID,下次发送心跳时将尝试重新注册。" "处理措施:重置客户端信息,下次发送心跳时将尝试重新注册。"
) )
self.client_uuid = None self.client_uuid = None
del local_storage["mmc_uuid"] # 删除本地存储的UUID self.private_key_pem = None
if "mofox_uuid" in local_storage:
del local_storage["mofox_uuid"]
if "mofox_private_key" in local_storage:
del local_storage["mofox_private_key"]
elif response.status == 404:
# 404 Not Found - 客户端未注册
logger.warning(
"此消息不会影响正常使用心跳发送失败404 Not Found: 客户端未注册。"
"处理措施:重置客户端信息,下次发送心跳时将尝试重新注册。"
)
self.client_uuid = None
self.private_key_pem = None
if "mofox_uuid" in local_storage:
del local_storage["mofox_uuid"]
if "mofox_private_key" in local_storage:
del local_storage["mofox_private_key"]
elif response.status == 403:
# 403 Forbidden - UUID无效或未注册
response_text = await response.text()
logger.warning(
f"此消息不会影响正常使用心跳发送失败403 Forbidden: UUID无效或未注册。"
f"响应内容: {response_text}"
"处理措施:重置客户端信息,下次发送心跳时将尝试重新注册。"
)
self.client_uuid = None
self.private_key_pem = None
if "mofox_uuid" in local_storage:
del local_storage["mofox_uuid"]
if "mofox_private_key" in local_storage:
del local_storage["mofox_private_key"]
else: else:
# 其他错误 # 其他错误
response_text = await response.text() response_text = await response.text()
logger.warning( logger.warning(
f"(此消息不会影响正常使用)状态未发送,状态码: {response.status}, 响应内容: {response_text}" f"(此消息不会影响正常使用)心跳发送失败,状态码: {response.status}, 响应内容: {response_text}"
) )
except Exception as e: except Exception as e:
import traceback import traceback
error_msg = str(e) or "未知错误" error_msg = str(e) or "未知错误"
logger.warning(f"(此消息不会影响正常使用)状态未发生: {type(e).__name__}: {error_msg}") logger.warning(f"(此消息不会影响正常使用)心跳发送出错: {type(e).__name__}: {error_msg}")
logger.debug(f"完整错误信息: {traceback.format_exc()}") logger.debug(f"完整错误信息: {traceback.format_exc()}")
async def run(self): async def run(self):
# 发送心跳 # 检查是否已注册
if self.client_uuid is None and not await self._req_uuid(): if not self.client_uuid or not self.private_key_pem:
logger.warning("获取UUID失败跳过此次心跳") if not await self._req_uuid():
return logger.warning("客户端注册失败,跳过此次心跳")
return
await self._send_heartbeat() await self._send_heartbeat()