update server

This commit is contained in:
2026-07-30 11:12:31 +08:00
commit 4312cb878c
99 changed files with 24034 additions and 0 deletions
+656
View File
@@ -0,0 +1,656 @@
# license_utils.py
"""RSA 验签许可证模块 —— 嵌入 exe,验签 + 每日巡检 + 防改系统时间。
用法(在 main.py 或主窗口 __init__ 中调用一次即可):
from license_utils import check_license, start_license_watchdog
check_license() # 启动时验签(失败则抛异常退出)
start_license_watchdog(interval_minutes=1440) # 后台每天巡检
"""
import json
import base64
import os
import sys
import threading
import time as _time_module
import datetime
from pathlib import Path
import requests
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.exceptions import InvalidSignature
# ============================================================
# 公钥(编译进 exe,可公开)—— 与 ControlPanel 使用的签名私钥配对
# 公钥更新由受控的发布流程完成,客户端不包含任何签发能力。
# ============================================================
# {{LICENSE_PUBLIC_KEY_START}}
PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEArQ4l2ePnl+p9yuUWcuOK
CQ4bzixyJKh8tPSVMwqI7u02v9qdrIPu5j3T0tl+qCNTLMP6WHd6s09M7bWuVTnU
220ljrdBb8opIzNGxTTri1k3JqMI95ljLwdEB6vp+ISyrdFKG4o+B+hDDvxkbyGH
2vKBG71Wrws4ujOEI1H8MDWDRMyGrHFQZZk1Sz6WkgWT+yjoZ8L0K3o0afIYw8F9
J50aaRQ9fvNW4EB+Pa5Yy5DQrNIs1smMVDpegdYt5uUMwEnfoS6Y6l98Gz7ljZ5n
6/WVaFb55XquAwsF/zq6oDfKBrAOBqzT2YYZklr8swlKKIJ3ExA1sd/dxhfZhidi
pqCvye6+cYa5GTRu9knzBsVPdzzhQC5AqKUuPglVJV8dQPfH7Nb7EP5wvNSgzpLT
9wsIoZXm9GXD0hApHvobSiZnpqY5g9InV7fQZyold2zFhHWpDieNjX0844gQafnH
Ue6JWRU4j3Wg37WDPbwkO3tQba2jbUQsLYomLGuohfkVAgMBAAE=
-----END PUBLIC KEY-----"""
# {{LICENSE_PUBLIC_KEY_END}}
# 许可证文件相对路径
LICENSE_FILE = "license.lic"
# 巡检间隔(分钟)
DEFAULT_CHECK_INTERVAL = 5
ONLINE_CHECK_TIMEOUT_SECONDS = 5
DEFAULT_OFFLINE_HOURS = 72
# 过期后宽限期(小时),给用户保存工作的时间
GRACE_PERIOD_HOURS = 2
# ============================================================
# 内部状态
# ============================================================
_startup_monotonic = _time_module.monotonic() # 软件启动时刻(不受系统时间影响)
_last_check_result = None
_verified_license = None
_verified_license_lock = threading.RLock()
_watchdog_started = False
_watchdog_lock = threading.Lock()
_last_online_success_monotonic = None
_on_expired_callback = None # 过期回调,可由外部设置
_on_grace_callback = None # 缓冲期回调
_on_log_callback = None # 日志回调,供 UI 状态栏显示
_warning_shown_states = {
"expiring_today": False, # 到期当天警告是否已弹窗
"expiring_soon": False, # 即将到期(30天内)警告是否已弹窗
"expired_grace": False, # 过期缓冲期警告是否已弹窗
}
# ============================================================
# GUI 线程安全工具
# ============================================================
def _invoke_on_qt_thread(func):
"""在 Qt 主线程中安全执行 func。
使用 QTimer.singleShot 将回调排队到主线程事件循环。
关键:必须传入 QApplication 作为 contextreceiver),否则 QTimer 会被调度到
当前线程(watchdog 是 daemon 线程,没有 Qt event loop),导致静默永不触发。
"""
try:
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is not None:
QTimer.singleShot(0, app, func)
return True
except Exception:
pass
# 兜底:没有 QApplication 时直接调用(GUI 还未初始化)
try:
func()
except Exception:
pass
return False
def _show_message_box(icon, title, message):
"""线程安全地显示 QMessageBox。
Args:
icon: 'critical', 'warning', 'information'
title: 弹窗标题
message: 弹窗内容
"""
def _show():
try:
from PySide6.QtWidgets import QApplication, QMessageBox
app = QApplication.instance()
if app is None:
return
if icon == 'critical':
QMessageBox.critical(None, title, message)
elif icon == 'warning':
QMessageBox.warning(None, title, message)
else:
QMessageBox.information(None, title, message)
except Exception:
pass
_invoke_on_qt_thread(_show)
# ============================================================
# 验签核心
# ============================================================
def _load_public_key():
"""从内嵌的 PEM 加载公钥"""
return serialization.load_pem_public_key(PUBLIC_KEY_PEM.encode())
def _validate_device_id(device_id):
"""确保新许可证中的目录键只能是 ``company/line``。"""
if not isinstance(device_id, str):
raise ValueError("许可证 device_id 必须是字符串")
segments = device_id.split("/")
if len(segments) != 2 or any(
not segment or segment in (".", "..") or "\\" in segment
for segment in segments):
raise ValueError("许可证 device_id 必须是 company/production-line 格式")
def _validate_payload(payload):
if not isinstance(payload, dict):
raise ValueError("许可证内容必须是对象")
new_fields = ("license_id", "company_id", "production_line_id", "device_id")
has_new_format = any(field in payload for field in new_fields)
if has_new_format:
missing = [field for field in new_fields
if not isinstance(payload.get(field), str) or not payload[field].strip()]
if missing:
raise ValueError(f"新许可证缺少标识字段: {', '.join(missing)}")
_validate_device_id(payload["device_id"])
else:
_log("旧许可证不支持在线撤销")
if not isinstance(payload.get("customer"), str) or not payload["customer"].strip():
raise ValueError("许可证缺少客户信息")
return has_new_format
def verify_license(lic_path=None):
"""验证许可证签名 + 有效期。
Args:
lic_path: 许可证文件路径,默认 exe 同级目录下的 license.lic
Returns:
dict: 许可证 payloadcustomer, expiry, issued 等)
Raises:
FileNotFoundError: 许可证文件不存在
InvalidSignature: 签名不匹配(伪造/篡改)
RuntimeError: 许可证已过期
ValueError: 许可证格式错误
"""
if lic_path is None:
# PyInstaller 打包后 sys.executable 是 exe 路径
exe_dir = Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path.cwd()
lic_path = exe_dir / LICENSE_FILE
if not os.path.exists(lic_path):
raise FileNotFoundError(f"许可证文件不存在: {lic_path}")
with open(lic_path, "r", encoding="utf-8") as f:
raw = f.read().strip()
# 解析: payload_base64 | signature_base64
if "|" not in raw:
raise ValueError("许可证格式错误")
payload_b64, signature_b64 = raw.split("|", 1)
signature = base64.b64decode(signature_b64)
# RSA-PSS SHA256 验签
try:
pub_key = _load_public_key()
pub_key.verify(
signature,
payload_b64.encode(), # 签名的是 base64 字符串本身
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH,
),
hashes.SHA256(),
)
except InvalidSignature:
raise InvalidSignature("许可证签名验证失败:文件可能被篡改")
# 解析载荷
try:
payload = json.loads(base64.b64decode(payload_b64))
except Exception:
raise ValueError("许可证内容解析失败")
_validate_payload(payload)
# 有效期检查(精确到小时)
expiry_str = payload.get("expiry")
if not expiry_str:
raise ValueError("许可证缺少过期时间")
# 兼容旧格式 YYYY-MM-DD(视为当天 23:59
if len(expiry_str) == 10:
expiry = datetime.datetime.strptime(expiry_str + " 23:59", "%Y-%m-%d %H:%M")
else:
expiry = datetime.datetime.strptime(expiry_str, "%Y-%m-%d %H:%M")
trusted_now = _get_trusted_now()
if trusted_now > expiry:
gap_days = (trusted_now - expiry).days
uptime_days = _get_uptime_days()
# 反时钟篡改:需同时满足两个条件才怀疑用户拨慢系统时间
# ① 软件运行不到 1 天(刚启动)
# ② 过期时间差超过 7 天(差距巨大 → 文件 mtime 暴露了真实时间)
# 缺失任一条件 → 真过期,直接报错:
# - gap 小(几分钟~几小时)→ 刚过期,正常报错
# - 软件跑了很久 → 正常使用中过期,正常报错
if uptime_days < 1 and gap_days > 7:
issued_str = payload.get("issued", expiry_str)
if len(issued_str) == 10:
issued = datetime.datetime.strptime(issued_str + " 00:00", "%Y-%m-%d %H:%M")
else:
issued = datetime.datetime.strptime(issued_str, "%Y-%m-%d %H:%M")
estimated_now = issued + datetime.timedelta(days=uptime_days)
if estimated_now <= expiry:
return payload
raise ExpiredError(
f"许可证已过期 (到期: {expiry_str})",
expiry=expiry,
)
return payload
def get_verified_license():
"""返回本进程已通过本地验证的许可证载荷,尚未验证时返回 ``None``。"""
with _verified_license_lock:
return dict(_verified_license) if _verified_license is not None else None
def _is_new_license(payload):
return all(payload.get(field) for field in (
"license_id", "company_id", "production_line_id", "device_id"))
def _api_url():
base_url = os.environ.get(
"REINLOOP_SERVER_URL", "http://ReinLoop.dominatedconvergence.com"
).rstrip("/")
return os.environ.get("REINLOOP_API_URL", f"{base_url}/api")
def _offline_limit_seconds():
try:
hours = float(os.environ.get("REINLOOP_LICENSE_OFFLINE_HOURS", DEFAULT_OFFLINE_HOURS))
except ValueError:
hours = DEFAULT_OFFLINE_HOURS
return max(0, hours) * 3600
def validate_license_online(payload):
"""验证可撤销许可证的在线状态。
网络故障只在超过离线时限后失效;服务端明确拒绝则立即返回 ExpiredError。
"""
global _last_online_success_monotonic
if not _is_new_license(payload):
return
try:
response = requests.post(_api_url(), json={
"type": "validateLicense",
"licenseId": payload["license_id"],
"deviceId": payload["device_id"],
}, timeout=ONLINE_CHECK_TIMEOUT_SECONDS)
response.raise_for_status()
result = response.json()
except (requests.RequestException, ValueError) as exc:
now = _time_module.monotonic()
if _last_online_success_monotonic is None:
_last_online_success_monotonic = _startup_monotonic
if now - _last_online_success_monotonic > _offline_limit_seconds():
raise ExpiredError("许可证在线校验超过离线宽限期") from exc
_log(f"许可证在线校验暂不可用: {exc}")
return
status = result.get("status")
if not result.get("success") or not result.get("valid") or status != "active":
reason = status or result.get("errMsg") or "invalid"
raise ExpiredError(f"许可证在线状态无效: {reason}")
if result.get("licenseId") not in (None, payload["license_id"]):
raise ExpiredError("许可证在线校验返回了不匹配的许可证")
_last_online_success_monotonic = _time_module.monotonic()
# ============================================================
# 时间可信度
# ============================================================
def _get_uptime_days():
"""软件已连续运行的天数(基于 monotonic,不受系统时间影响)"""
return (_time_module.monotonic() - _startup_monotonic) / 86400
def _get_trusted_now():
"""多源交叉校验获取可信日期时间(精确到小时)。
取系统时间和文件时间的最大值,防止用户回拨系统时间。
"""
candidates = []
# 1. 系统时间
candidates.append(datetime.datetime.now())
# 2. 软件启动时记录的"最早可能时间"
# monotonic 计时推导出的启动时间
startup_guess = datetime.datetime.now() - datetime.timedelta(
days=_get_uptime_days()
)
candidates.append(startup_guess)
# 3. 系统文件的修改时间(不易被用户修改)
system_files = []
if sys.platform == "darwin":
system_files = [
"/System/Library/CoreServices/SystemVersion.plist",
"/usr/bin/python3",
]
elif sys.platform == "win32":
system_files = [
r"C:\Windows\System32\ntoskrnl.exe",
r"C:\Windows\explorer.exe",
]
for sf in system_files:
if os.path.exists(sf):
try:
mtime = os.path.getmtime(sf)
candidates.append(datetime.datetime.fromtimestamp(mtime))
except OSError:
pass
# 取最大值(真时间 ≥ 所有候选值。用户可能回拨,但不能让其他文件"变新")
return max(candidates)
# ============================================================
# 后台巡检
# ============================================================
def start_license_watchdog(interval_minutes=DEFAULT_CHECK_INTERVAL):
"""启动后台许可证巡检线程。
Args:
interval_minutes: 检查间隔(分钟),默认 1440(24小时)
"""
global _watchdog_started
with _watchdog_lock:
if _watchdog_started:
return
_watchdog_started = True
t = threading.Thread(
target=_watchdog_loop,
args=(max(5, interval_minutes),),
daemon=True,
name="license-watchdog",
)
t.start()
def _watchdog_loop(interval_minutes):
while True:
_time_module.sleep(interval_minutes * 60)
try:
payload = verify_license()
with _verified_license_lock:
global _verified_license
_verified_license = dict(payload)
validate_license_online(payload)
expiry_str = payload["expiry"]
# 解析到期时间(兼容旧格式)
if len(expiry_str) == 10:
expiry = datetime.datetime.strptime(expiry_str + " 23:59", "%Y-%m-%d %H:%M")
else:
expiry = datetime.datetime.strptime(expiry_str, "%Y-%m-%d %H:%M")
trusted_now = _get_trusted_now()
hours_left = (expiry - trusted_now).total_seconds() / 3600
days_left = int(hours_left / 24)
if hours_left < 0:
# 已过期(不应走到这里,verify_license 会抛 ExpiredError
_log(f"⚠ 许可证已过期 ({expiry_str})")
_show_message_box('critical', "许可证已过期",
f"您的许可证已于 {expiry_str} 到期!\n请尽快联系厂商续期。")
elif hours_left <= 24:
if not _warning_shown_states["expiring_today"]:
_warning_shown_states["expiring_today"] = True
_log(f"⚠ 许可证将在今天到期 ({expiry_str})")
_show_message_box('warning', "许可证即将到期",
f"您的许可证将于今天 {expiry_str} 到期!\n"
f"剩余约 {int(hours_left)} 小时,请及时联系厂商续期。")
elif days_left <= 30:
if not _warning_shown_states["expiring_soon"]:
_warning_shown_states["expiring_soon"] = True
_log(f"⚠ 许可证将在 {days_left} 天后到期 ({expiry_str})")
_show_message_box('warning', "许可证即将到期",
f"您的许可证将在 {days_left} 天后({expiry_str})到期\n请提前联系厂商续期。")
except ExpiredError as e:
_handle_expired(e)
except Exception as e:
_log(f"许可证巡检异常: {e}")
def _handle_expired(error):
"""处理许可证过期"""
_log(f"{error}")
# 弹窗:许可证已过期
_show_message_box('critical', "许可证已过期",
f"{error}\n\n软件将在 {GRACE_PERIOD_HOURS} 小时缓冲期后自动退出,\n请及时保存工作并联系厂商续期。")
if _on_expired_callback:
_on_expired_callback(str(error))
# 宽限期:给用户时间保存工作
grace_start = _time_module.monotonic()
grace_seconds = GRACE_PERIOD_HOURS * 3600
if not _warning_shown_states["expired_grace"]:
_warning_shown_states["expired_grace"] = True
if _on_grace_callback:
_on_grace_callback(GRACE_PERIOD_HOURS)
# 半小时后提醒一次
warned_half = False
while _time_module.monotonic() - grace_start < grace_seconds:
elapsed = _time_module.monotonic() - grace_start
if not warned_half and elapsed > grace_seconds / 2:
warned_half = True
_show_message_box('warning', "许可证已过期",
f"缓冲期剩余约 {GRACE_PERIOD_HOURS // 2} 小时,\n请尽快保存工作!")
_time_module.sleep(60) # 每分钟检查一次
# 宽限期过,强制退出
_log("宽限期已过,软件即将退出")
_show_message_box('critical', "许可证已过期",
"缓冲期已结束,软件即将退出。\n请联系厂商续期后重新启动。")
# 给 5 秒做最后的清理
_time_module.sleep(5)
os._exit(1)
def set_on_expired(callback):
"""设置过期回调: callback(message: str)"""
global _on_expired_callback
_on_expired_callback = callback
def set_on_grace(callback):
"""设置宽限期回调: callback(hours: int)"""
global _on_grace_callback
_on_grace_callback = callback
def set_on_log(callback):
"""设置日志回调: callback(message: str)
所有许可证关键日志会同时输出到此回调,供 UI 状态栏显示。
"""
global _on_log_callback
_on_log_callback = callback
# ============================================================
# 便捷入口
# ============================================================
class ExpiredError(RuntimeError):
"""许可证过期异常"""
def __init__(self, message, expiry=None):
super().__init__(message)
self.expiry = expiry
def _log(msg):
"""终端日志 + UI 回调(不依赖任何 UI 层)"""
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
formatted = f"[License {timestamp}] {msg}"
print(formatted)
# 同步推送到 UI 状态栏(如果已注册回调)
if _on_log_callback:
try:
_on_log_callback(str(msg))
except Exception:
pass
def check_license(lic_path=None):
"""启动时调用:验证许可证,通过则返回 payload。
在 main.py 的 main() 函数开头调用一次即可。
内部会自动启动后台巡检线程。
Returns:
dict: 许可证载荷
Raises:
SystemExit: 验签失败或已过期(启动阶段直接退出)
"""
try:
payload = verify_license(lic_path)
environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip()
if (_is_new_license(payload) and environment_device_id
and environment_device_id != payload["device_id"]):
raise ValueError("REINLOOP_DEVICE_ID 与许可证 device_id 不一致")
validate_license_online(payload)
with _verified_license_lock:
global _verified_license
_verified_license = dict(payload)
start_license_watchdog()
expiry = payload.get("expiry", "未知")
customer = payload.get("customer", "未知")
_log(f"✅ 许可证有效 | 客户: {customer} | 到期: {expiry}")
return payload
except FileNotFoundError as e:
_log(f"{e}")
_show_error_and_exit(
"未找到许可证文件",
"请将 license.lic 放到软件根目录,然后重新启动程序。\n\n"
"如有疑问,请联系厂商获取有效的许可证文件。"
)
except InvalidSignature as e:
_log(f"{e}")
_show_error_and_exit(
"许可证验证失败",
"许可证签名校验不通过,文件可能已被篡改。\n\n"
"请使用原始签发的 license.lic 文件,\n"
"或联系厂商重新签发。"
)
except ExpiredError as e:
_log(f"{e}")
_show_error_and_exit(
"许可证已过期",
f"您的许可证已于 {e.expiry} 到期。\n\n"
"请联系厂商续期,获取新的许可证文件后重新启动。"
)
except Exception as e:
_log(f"❌ 许可证检查异常: {e}")
_show_error_and_exit(f"许可证校验失败: {e}", str(e))
def _show_error_and_exit(title, detail=""):
"""显示错误弹窗并退出(兼容 GUI 和无 GUI 模式)。
Args:
title: 弹窗标题(简短概要)
detail: 弹窗正文(详细说明和操作建议)
"""
message = f"{title}\n\n{detail}" if detail else title
# 尝试 GUI 弹窗
try:
from PySide6.QtWidgets import QApplication, QMessageBox
app = QApplication.instance()
if app is not None:
QMessageBox.critical(None, title, detail or title)
else:
# 无 QApplication 实例时,尝试创建一个临时的
try:
app = QApplication(sys.argv[:1])
QMessageBox.critical(None, title, detail or title)
except Exception:
pass
except Exception:
pass
print(f"\n{'='*50}")
print(message)
print(f"{'='*50}\n")
sys.exit(1)
# ============================================================
# 辅助:获取许可证信息(供 UI 显示)
# ============================================================
def get_license_info(lic_path=None):
"""读取许可证信息(不做过期检查),供 UI 显示。
Returns:
dict | None: 许可证信息,文件不存在则返回 None
"""
try:
if lic_path is None:
exe_dir = (
Path(sys.executable).parent
if getattr(sys, 'frozen', False)
else Path.cwd()
)
lic_path = exe_dir / LICENSE_FILE
if not os.path.exists(lic_path):
return None
with open(lic_path, "r", encoding="utf-8") as f:
raw = f.read().strip()
payload_b64 = raw.split("|")[0]
payload = json.loads(base64.b64decode(payload_b64))
# 先验签保证内容可信
verify_license(lic_path)
return {
"customer": payload.get("customer", "未知"),
"expiry": payload.get("expiry", "未知"),
"issued": payload.get("issued", "未知"),
"features": payload.get("features", "*"),
}
except Exception:
return None