first commit

This commit is contained in:
wood911010 committed 2026-02-27 14:29:48 +08:00
commit 5ee52f09bc
52 files changed
+29721

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
# License Manager SDK (Python)
`license-manager-sdk-python` 是 ThingsPanel License Manager 系列授权管控中枢的官方 Python 客户端。它与后端的 SaaS 管控网络无缝集成,专门为 B端/G端 软件、算法模型落地以及各种私有化部署提供**从在线激活到离线侧载、从机器指纹绑定到后台心跳封禁**的全套授权治理能力。
## 🌟 核心能力架构
本 SDK 提供多层防御机制和灵活的商业策略触点:
* **🛡️ 强硬件指纹锁定**:全平台自动抓取 `MAC`, `CPU`, `Hostname` 甚至自定义特征生成唯一指纹,实现真正的“一机一码”,阻断跨设备白嫖。
* **🔒 数据强防篡改 (RSA-PSS + AES)**:网络传输与本地落地的 `license.lic` 均由不可逆的非对称 RSA 私钥重签名,本地持久化缓存以动态 Nonce + AES-GCM 加密,彻底杜绝本地抓包与反汇编篡改证书。
* **🔌 混合鉴权 (Online First & Offline Fallback)**:
* **在线模式**:输入一串极简的产品激活码,设备主动向 SaaS 面板请求注册激活,后台自动派发许可并建立心跳档案。
* **离线侧载 (`&` 魔法)**:对于军工/内网纯断网环境,支持用户将授权码与被网闸外的终端代签的 `Base64` 离线封套组合为 `AuthCode&Envelope` 格式喂给 SDK,自动实现物理隔离区的脱机秒激活验证。
* **💓 云端“遥控”心跳系统**:底层常驻心跳守护线程。无论何时在 SaaS 控制台点击“禁用”、“退款解绑”或“过期”,客户端下一秒的 `Heartbeat` 立即获取并触发死亡回调,瞬时阻断正在运行的业务。
## 📦 快速接入指南
### 1. 基础依赖
本 SDK 坚持最小极简原则,除标准库外,仅需依赖:
```bash
pip install requests cryptography
```
### 2. 初始化与使用
以下是如何在您的 Python 业务代码中无侵入式植入防线的示例:
```python
import time
import logging
from license_manager.client import Client, ClientOptions, Callbacks
from license_manager.config import Config
from license_manager.models import LicensePayload
def main():
# 1. 配置您的 SaaS 网关与应用身份
cfg = Config(
server="http://43.136.177.43:18081",
product="vision-vm-studio", # 您在 SaaS 后台创建的产品标识
version="1.0.0", # 软件版本(可作升级校验控制)
authorization_code="YOUR_AUTH_CODE", # 客户输入的授权码(可带&脱机包)
hardware_fields=["cpu", "hostname", "mac"],
license_file_path="license.lic", # 授权本地落地缓存
public_key_pem=b"-----BEGIN PUBLIC KEY-----\n..." # 从后台获取的公钥PEM串
)
# 2. 注入业务阻断/状态同步回调钩子
def on_license_updated(lic: LicensePayload):
logging.info(f"-> 收到新证书!将于 {lic.expires_at} 过期")
def on_heartbeat_error(err: Exception):
logging.warning(f"-> 心跳预警/暂无法连接服务器: {err}")
# 如果 err 中包含 300006,意味着不仅是断网,而是您在后台强行吊销了该证
if "300006" in str(err):
logging.error("业务级致盲:授权已被吊销或清理!")
def on_activation_required(reason: str):
logging.error(f"-> 致命剥权: {reason}")
# 在此处调用 sys.exit() 或者杀除核心算法句柄
# 3. 拉起防线
callbacks = Callbacks(
on_license_updated=on_license_updated,
on_heartbeat_error=on_heartbeat_error,
on_activation_required=on_activation_required,
on_heartbeat_ping=lambda: logging.info("Ping...")
)
try:
# SDK 启动时会自动根据情况:尝试载入本地有效证 -> 发起在线激活请求 -> 降级解密离线包套 -> 启动轮询
client = Client(cfg, ClientOptions(callbacks=callbacks))
except Exception as e:
logging.error(f"授权初始化崩溃: {e}")
return
# 4. 经过上一步的洗礼,此时必定有合规的证
lic = client.current_license()
logging.info(f"授权成功并生效,企业标识: {lic.license_key}")
# +++ 以下为您的核心商业逻辑 +++
# ...
# 每次跑巨幅算力前均可调用安全验证:
client.validate()
# 优雅退出
client.close()
if __name__ == "__main__":
main()
```
## 🛠️ API 模块详述
- `license_manager.config.Config`: SDK 的基石配置,负责定义产品身份、硬件采样维度和公钥验签证书。
- `license_manager.client.Client`: 主控 Facade。您只需持有它的实例即可。
- `license_manager.hardware.DefaultHardwareProvider`: 跨平台设备指纹提取器,默认支持 `mac`, `cpu`, `hostname`, `disk` 等硬件特征的稳定提取与加密散列化。
- `license_manager.heartbeat.HeartbeatManager`: 藏在底层的定时器,会按照 `Config` 或服务端下发的动态间隔,不断向后台上报指纹请求对齐 `LicensePayload` 履历。
## 💡 示例应用 (Examples)
SDK 内置了两个详尽的参考案例:
- `examples/basic/main.py`: 无依赖的纯终端 SDK 接入演示。
- `examples/客户案例1/client-demo/demo.py`: **基于 `Tkinter` 的高度还原 GUI 客户端 Demo**,展示了极其直观的离线载入、在线阻断日志以及动态修改 SaaS 地址的商业端防线体验。您可以直接运行或使用 PyInstaller 打包为 `exe`。
View File
Whitespace-only changes.
+307
View File
@@ -0,0 +1,307 @@
{
"schemes": [
"http",
"https"
],
"swagger": "2.0",
"info": {
"description": "软件授权管理平台API文档(精简版,仅包含激活与心跳接口)",
"title": "License Manager API",
"termsOfService": "http://swagger.io/terms/",
"contact": {
"name": "API Support",
"url": "http://www.swagger.io/support",
"email": "support@swagger.io"
},
"license": {
"name": "MIT",
"url": "https://opensource.org/licenses/MIT"
},
"version": "1.0"
},
"basePath": "/",
"paths": {
"/api/v1/activate": {
"post": {
"description": "客户端使用授权码激活软件,获取许可证文件",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"许可证激活"
],
"summary": "激活许可证",
"parameters": [
{
"description": "激活请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.ActivateRequest"
}
}
],
"responses": {
"200": {
"description": "激活成功",
"schema": {
"allOf": [
{
"$ref": "#/definitions/models.APIResponse"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/models.ActivateResponse"
}
}
}
]
}
},
"400": {
"description": "请求参数无效",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"404": {
"description": "授权码不存在",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"409": {
"description": "授权码已锁定或已过期",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"429": {
"description": "激活数量已达上限",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "服务器内部错误",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
}
},
"/api/v1/heartbeat": {
"post": {
"description": "客户端定期发送心跳,更新在线状态和使用数据",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"许可证激活"
],
"summary": "心跳检测",
"parameters": [
{
"description": "心跳请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.HeartbeatRequest"
}
}
],
"responses": {
"200": {
"description": "心跳成功",
"schema": {
"allOf": [
{
"$ref": "#/definitions/models.APIResponse"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/models.HeartbeatResponse"
}
}
}
]
}
},
"400": {
"description": "请求参数无效",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"404": {
"description": "许可证不存在",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"409": {
"description": "许可证已被撤销",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "服务器内部错误",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
}
}
},
"definitions": {
"models.APIResponse": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"data": {},
"message": {
"type": "string"
},
"timestamp": {
"type": "string"
}
}
},
"models.ActivateRequest": {
"type": "object",
"required": [
"authorization_code",
"hardware_fingerprint"
],
"properties": {
"authorization_code": {
"description": "授权码,必填",
"type": "string"
},
"device_info": {
"description": "设备信息,可选",
"type": "object",
"additionalProperties": true
},
"hardware_fingerprint": {
"description": "硬件指纹,必填",
"type": "string"
},
"software_version": {
"description": "软件版本,可选",
"type": "string"
}
}
},
"models.ActivateResponse": {
"type": "object",
"properties": {
"heartbeat_interval": {
"description": "心跳间隔(秒)",
"type": "integer"
},
"license_file": {
"description": "base64编码的加密许可证文件",
"type": "string"
},
"license_key": {
"description": "许可证密钥",
"type": "string"
}
}
},
"models.HeartbeatRequest": {
"type": "object",
"required": [
"hardware_fingerprint",
"license_key"
],
"properties": {
"config_updated_at": {
"description": "客户端配置更新时间,可选",
"type": "string"
},
"hardware_fingerprint": {
"description": "硬件指纹,必填",
"type": "string"
},
"license_key": {
"description": "许可证密钥,必填",
"type": "string"
},
"software_version": {
"description": "软件版本,可选",
"type": "string"
},
"usage_data": {
"description": "使用数据,可选",
"type": "object",
"additionalProperties": true
}
}
},
"models.HeartbeatResponse": {
"type": "object",
"properties": {
"config_updated": {
"description": "配置是否有更新",
"type": "boolean"
},
"heartbeat_interval": {
"description": "下次心跳间隔(秒)",
"type": "integer"
},
"license_file": {
"description": "base64编码的新许可证文件(如有更新)",
"type": "string"
},
"status": {
"description": "许可证状态",
"type": "string"
}
}
},
"models.ErrorResponse": {
"type": "object",
"properties": {
"code": {
"description": "业务错误码,如 \"100001\", \"900001\"",
"type": "string"
},
"message": {
"description": "错误描述信息",
"type": "string"
},
"timestamp": {
"description": "错误发生时间",
"type": "string"
}
}
}
},
"securityDefinitions": {
"BearerAuth": {
"description": "Bearer token for authentication",
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}
+82
View File
@@ -0,0 +1,82 @@
# License Manager Go SDK 需求
## 背景
- 当前客户端 demo 直接对接服务端,集成复杂且不利于复用。
- 多个需要授权的产品希望以库的形式快速接入授权能力。
- 需要一个接口简洁、易于扩展、对调用方入侵极低的 Go SDK。
## 目标
1. 封装授权激活、许可证校验、心跳续期等流程,减少业务方重复开发。
2. 提供清晰的配置与生命周期管理,支持 CLI、服务和嵌入式场景。
3. 兼容已有 License Manager 服务端接口,未来接口升级时对调用方透明。
4. 支持在线 / 离线两种工作模式,满足联网和隔离环境的授权需求。
## SDK 架构
| 模块 | 职责 |
|------|------|
| `license.Client` | SDK 入口,负责生命周期管理。 |
| `config` | 统一的配置结构体,支持代码、环境变量、配置文件三种来源。 |
| `hardware` | 硬件指纹采集,默认实现可被自定义扩展。 |
| `activation` | 激活接口封装、授权码加载、许可证缓存。 |
| `validator` | 对许可证内容、有效期、功能限制进行校验。 |
| `heartbeat` | 心跳轮询、动态间隔调整、失败退避策略。 |
| `storage` | 许可证加解密与本地持久化,支持自定义存储后端。 |
| `logger` | 统一日志接口,默认输出到标准输出,可注入业务日志实现。 |
### 硬件指纹采集策略
1. **标准组合指纹(默认)**:采集首个物理 MAC、CPU 型号、主机 ID,组合字符串后做哈希。
2. **可插拔字段选择**:调用方可通过配置决定纳入哪些硬件字段(如磁盘序列号、主板序列号等),SDK 保证字段顺序与哈希算法一致。
3. **自定义采集器**:提供 `hardware.Provider` 接口,允许业务方完全自定义指纹生成逻辑(适配虚拟化、容器等环境)。
4. **匿名化/脱敏模式**:支持在上报前对字段进行额外哈希或加盐,满足隐私/合规要求。
5. **实时采集策略**:每次需要校验时即时采集,避免任何形式的本地缓存,以消除被复制或伪造的风险;若采集失败需有清晰的回退与错误提示。
## 核心功能需求
1. **初始化**
- 必填配置:服务端地址(离线模式可选)、产品标识、版本号、授权码来源或许可证文件路径、服务端 RSA 公钥(可通过路径或内容提供)。
- 可选配置:心跳间隔、超时、日志级别、硬件指纹采集策略、自定义 HTTP 客户端。
2. **许可证加载与激活**
- **在线模式**:启动优先读取本地许可证;不存在或失效时自动激活,激活请求包含硬件指纹、产品信息、授权码,支持附加自定义字段。激活成功后自动校验并落盘,加解密沿用 AES-GCM;需使用配置中提供的 RSA 公钥验证服务端签名。
- **离线模式**:仅校验本地许可证文件合法性(签名/有效期/硬件绑定),不访问网络;当本地许可证缺失或过期时返回明确错误,由业务方决定更新方式。需通过配置显式提供许可证文件路径(默认 `license_code/license.lic` 可覆盖)及 RSA 公钥拷贝,保证可独立校验签名。
3. **许可证校验**
- 校验内容:签名/加密、有效期、硬件绑定、功能/限制字段。
- 提供同步 API(`Validate()`)供业务方在关键操作前主动检查。
- 提供只读访问接口(如 `client.CurrentLicense()`),可在业务运行时获取最新许可证的功能限制、配额等字段,便于执行业务级限制判断;当许可证更新时需即时刷新缓存并触发回调。
4. **心跳服务**
- 仅在在线模式启用:成功激活后自动启动,默认间隔 300s,可从服务器动态调整。
- 支持暂停/恢复接口,网络异常时指数退避并记录原因。
- 服务器返回新的许可证时自动替换并回调通知。
- 离线模式不启动心跳,SDK 需暴露显式 API 让业务层触发本地校验。
5. **事件与回调**
- 需支持 `OnLicenseUpdated`, `OnHeartbeatError`, `OnActivationRequired` 等回调。
- 回调在独立 goroutine 执行,避免阻塞主流程。
6. **错误处理**
- 统一错误类型,区分可重试与不可重试场景。
- 提供错误码枚举,便于业务层按类型处理。
7. **线程安全**
- SDK 所有公开方法需并发安全,核心状态(许可证、心跳)内部加锁。
8. **可测试性**
- 对 HTTP、时间、硬件指纹、存储等外部依赖提供接口,方便 Mock。
## 使用方式
```go
cfg := license.Config{
Server: "https://license.example.com",
Product: "edge-gateway",
Version: "2.3.1",
AuthCode: license.FromFile("license_code/authorization_code.txt"),
PublicKey: license.FromFile("license_code/rsa_public_key.pem"),
}
client, err := license.NewClient(cfg)
defer client.Close()
if err != nil { /* ... */ }
if err := client.Validate(context.Background()); err != nil {
// 业务自定义处理
}
```
## 交付物
- SDK 源码与 go.mod。
- `README`:安装、Quick Start、常见问题。
- 示例程序:最小化控制台示例 + 与现有 client-demo 等价的高级示例。
+46
View File
@@ -0,0 +1,46 @@
# License Manager Python SDK 设计
## 1. 设计目标
- 提供统一、低侵入的 Python SDK 以封装激活、校验、心跳、许可证管理流程。
- 兼容现有 License Manager 服务端接口,在线/离线模式对业务透明。
- 高可扩展与可测试性:硬件指纹、存储、网络协议处理器等核心可替换(duck typing 理念)。
## 2. 交付范围
- 符合 PEP 标准的 Python 包源码。
- 依赖描述文件(`requirements.txt` / `setup.py`)。
- 示例:最小化直接调用的基础示例 `examples/basic/main.py`。
- 面向 Python 的技术文档。
## 3. 核心模块设计说明
- **client.Client (门面模式)**
- 组合各模块,提供 `__init__`, `validate`, `current_license`, `close` 等方法。
- 维护内部状态,并通过 `threading.Lock` 等保证线程安全访问当前 License 版本。
- **config (配置与数据校验)**
- 定义明确具有 Type hints 的类或 `dataclasses`,支持将环境及文件属性覆盖。
- 静态检查:在线模式验证服务端必须 URL / 密码文件;离线模式检验证书位置。
- **activation (激活)**
- 基于标准的 `requests`(带连接池和自动超时重试策略)发起网络激活通信。
- 接收加密串后调用 validator 核验,成功后通过 storage 保存。
- **validator (强安全验证)**
- 依托强大的 `cryptography` 包处理 RSA (建议为 PSS padding 等现代方法,具体匹配服务端实现)。
- 对解包出的许可证时间 `ExpiresAt`、硬件指纹进行 CPU 负载级的精确比较并验证未发生人为修改。
- **heartbeat (心跳维护)**
- 取代 Go 的 goroutine 使用,改用由 `threading.Thread` 的轮询服务。设置 `daemon=True` 防止卡顿主进程。
- 设计线程安全的 `threading.Event()` 实现如 `pause`, `resume`, `stop` 这些外部调度接口。
- 对于网络闪断具有一定指数回退 (Exponential backoff) 容量。
- **hardware (多态指纹搜集)**
- 抽象采集策略,利用系统底层命令及 `uuid`、`platform` 以避免绑定复杂的原生编译依赖(如果在 CPython 环境可引入 psutil 或仅靠 standard libs 执行降级采集策略)。
- **storage (凭证安全落地)**
- 文件加解密适配 `cryptography` 在 AES-GCM 算法下使用 256bit 动态密钥做密文打包,防本地脱机逆向嗅探。
- **logger & exceptions (日志体系监控)**
- 使用标准的 `logging` 获取当前全局记录器。
- 设计带有分类标记的 `LicenseManagerError`, `NetworkError`, `ValidationError` 提供可分辨的控制流分支。
## 4. 关键生命周期总结
1. **实例化**:加载配置 → 初始化硬件信息/日志/存储机制 → 调用 validator 验算本地既有证书 → 按需挂起并调用 activation 发在线网路激活申请。
2. **后台伴生**:启动 heartbeat daemon → 在给定 `interval` 进行上游 pinging → 若上游发来更新证,回调主线程数据结构;若请求重新激活,则触发 callback 进入阻塞警报。
3. **断网本地态**:应用随时拉起 `client.validate()`。验证不通过抛出 SDK 特有 `Exception`,应用层捕获异常并决定关停自身业务。
## 5. 对比补充与潜在风险
- **包依赖风险**:为降低部署繁杂度,若系统有条件应考虑避免引用含有大量 C 扩展的包,优先利用 Python 3 自带库封装;必须时则明确说明并列在依赖清单首位 (如 `cryptography`)。
- **定时锁和 GIL 干扰**:心跳网络收发本质是 I/O 阻塞型操作,采用线程天然适合,且不易阻断业务代码,但在多线程中覆盖验证缓存 `current_license` 必须添加明确锁保护(`threading.RLock()`),确保不产生竞争读写现象。
+22
View File
@@ -0,0 +1,22 @@
# TaskLoop Iteration Log
## Sub-task 0: 任务拆解与开发计划制定
... (已成功)
## Sub-task 1: 基础设施层开发 (Config, Models, Hardware)
... (已成功)
## Sub-task 2: 加解密及校验层 (Storage, Validator)
... (已成功)
## Sub-task 3: 网络层与心跳组件 (Activation, Heartbeat)
... (已成功)
## Sub-task 4: 核心组装层 (Client Facade)
- **所做工作**: 编写了 `client.py`,组合了 `Config`, `HardwareProvider`, `Store`, `Validator` 以及 API Services。
- **验证方式**: `test_batch4.py` 执行无网络状态下的初始化脱机错误测试,正常抛错被捕获。
- **状态**: 成功。
## Sub-task 5: 最终交付与 Example (Basic Demo)
- **所做工作**: 创建了 `examples/basic/main.py` 以复现 Go 版本的 `callbacks` 和 `thread` 阻塞。
- **状态**: 成功。至此,全部 5 个 DAG 批次的 SDK 底层向 Python 的翻译工作已 100% 达成。
+17
View File
@@ -0,0 +1,17 @@
# 项目上下文 (Project Context)
## 架构概览 (Architecture Overview)
License Manager SDK (Python): 一个轻量级的 SDK,旨在完全迁移 Go 版本的架构和生命周期概念。
## 关键技术决策 (Key Technical Decisions)
- 使用 `taskloop` 工作流按 MPE (最小程序实体) 级别迭代和测试。
- 使用 Python 标准和常用组件:网络请求使用 `requests`,RSA/AES 验证加解密使用 `cryptography`。
## 当前状态 (Current State)
已通过 TaskLoop 的 DAG 结构完成开发计划的制定。所有 MPE 已识别且定义了验收标准。代码编写与执行尚未开始。
## 已知问题与风险 (Known Issues / Risks)
- 在暂停心跳 (heartbeat) 时需要优雅地处理后台线程的中断。
## 领域知识 (Domain Knowledge)
- 许可证生命周期:在线激活 (Activate) -> 本地加密存储 (Store Locally: AES-GCM) -> 验证签名与防篡改 (Validate: RSA) -> 保持在线心跳 (Heartbeat: Keep-Alive)。
+63
View File
@@ -0,0 +1,63 @@
# License Manager SDK (Python) 翻写计划
## 目标与概述
将 `license-manager-sdk-go` 的核心功能完整翻写为 Python 版本,保持相同的架构设计模式,以便在 Python 项目中无缝集成。SDK 需要支持:
1. **在线/离线激活与校验**
2. **硬件指纹采集**(MAC, Hostname, CPU, Memory)
3. **本地加密存储**(AES-GCM)
4. **RSA 签名验证**
5. **后台心跳与生命周期管理**(自动心跳、断网重试、手动控制)
## 架构与模块映射映射 (Go -> Python)
| Go Module | Python Module | 功能描述 |
| :--- | :--- | :--- |
| `config` | `license_manager.config` | SDK 配置定义与校验(`Config` 类) |
| `models` | `license_manager.models` | 数据模型定义(Pydantic 或 Dataclasses) |
| `hardware` | `license_manager.hardware` | 硬件特征采集(跨平台 mac, hostname, cpu, memory 获取) |
| `validator` | `license_manager.validator` | 对 payload 的 RSA 校验与防篡改验证 |
| `storage` | `license_manager.storage` | 基于 AES-GCM 的本地凭证加解密与存储 |
| `activation` | `license_manager.activation` | 封装向远端发起的激活 HTTP 请求 |
| `heartbeat` | `license_manager.heartbeat` | 封装心跳请求,及基于守护线程的 `HeartbeatManager` 心跳定时发送 |
| `license` | `license_manager.client` | 暴露给业务的核心入口 `Client` |
| `logger` | `license_manager.logger` | 标准 `logging` 封装 |
| `internal/httpclient` | *融合到请求中* | 推荐依赖 `requests` 库或内置 `urllib`,统一超时和重试 |
## 详细实施步骤
### 1. 基础模型与配置 (Day 1)
- **`models.py`**: 定义 `ActivateRequest`, `ActivateResponse`, `HeartbeatRequest`, `HeartbeatResponse`, `LicensePayload`。使用 Python 内置的 `dataclasses` 或 `pydantic`(如允许第三方依赖)。
- **`config.py`**: 定义 `Config` 类,实现参数校验、默认值填充(如默认 5 分钟心跳,默认拉取文件等)。
### 2. 硬件指纹采集 (Day 1)
- **`hardware.py`**:
- `mac`: 使用 `uuid.getnode()` 或第三方 `getmac` 库处理跨平台网卡 MAC 获取。
- `hostname`: 使用 `socket.gethostname()`。
- `cpu`/`memory`: 使用内置 `os`, `platform` 或第三方 `psutil` 获取。
### 3. 加解密与校验核验 (Day 2)
- 需要依赖常用的加解密库,如 `cryptography` 或 `pycryptodome`。
- **`storage.py`**: 实现 AES-GCM 加密,保存到本地文件。
- **`validator.py`**: 实现 RSA (PKCS#1 v1.5 或 PSS) 签名验证模块,确认下发的 License 数据未被篡改,并校验 `ExpiresAt` 以及硬件指纹比对。
### 4. HTTP 网络与心跳守护线程 (Day 3)
- **`activation.py`**: 封装激活接口。
- **`heartbeat.py`**:
- `HeartbeatService`: 封装心跳请求接口。
- `HeartbeatManager`: 包含一个 `threading.Thread` 或 `asyncio` 任务,支持 `start()`, `pause()`, `resume()`, `stop()` 机制。
### 5. 核心 Client 组装 (Day 4)
- **`client.py`**:
- 实现 `Client` 类作为门面 (Facade)。
- `__init__`: 组织 config, hardware, validator, storage 等初始化,并自动尝试加载本地缓存或发起在线请求。
- 实现 `current_license()`, `validate()`, `pause_heartbeat()`, `resume_heartbeat()`, `close()`。
### 6. 测试与示例 (Day 5)
- **`examples/basic/main.py`**: 对标 Go 的 `examples/basic`,展示线上模式、配置回调、获取许可限制等用法。
- **单元测试**: 对硬件采集、加解密、校验算法编写 pytest 测试。
## Python 依赖建议
- **HTTP/网络**: `requests`
- **加密与签名**: `cryptography`
- **模型验证**: `pydantic` (可选)
- **硬件指标扩展**: `psutil` (可选)
+45
View File
@@ -0,0 +1,45 @@
# 项目全局认知
本文档主要负责梳理 License Manager 面向 Python 开发者翻写过程中的整体文件存放策略,以及包结构的宏观认知。
由于原始开发使用 Go 语言,部分文件习惯(如根域名即包名)并不完全适用 Python(考虑到 Python 中不合法的 "-" 命名)。因此,对于 Python SDK,我们采取如下被业界广泛遵循的标准 Python 包(Package)部署和命名规范。
## 目录结构(全景)
```text
license-manager-sdk-python/ # 项目根目录(Repo)
├── docs/ # 相关的工程文档目录
│ ├── 项目全局认知.md # 当前文档:建立对项目文件夹分布的宏观理解
│ ├── design.md # 核心设计决策(基于 Python 特性适配过的系统模型)
│ ├── 翻写计划.md # 各个批次的 DAG 执行计划和阶段任务
│ ├── iteration-log.md # TaskLoop:执行反馈及尝试日志
│ └── project-context.md # TaskLoop:微观状态记忆
├── license_manager/ # 【核心源码包】符合 PEP8 的包名,供外部导入
│ ├── __init__.py # 暴露出 SDK 的唯一公开类 Client
│ ├── client.py # 门面层Facade,聚合 Config/Hardware/Validator 等模块
│ ├── config.py # 配置解析(DataClass / Pydantic 等模型)
│ ├── models.py # 数据结构,涵盖 Request/Response 及许可证字段定义
│ ├── hardware.py # 跨平台的硬件信息(mac, hostname, cpu等)采集器
│ ├── storage.py # AES-GCM 安全存储持久化器
│ ├── validator.py # 面向 payload 进行 RSA 验签与防篡改逻辑
│ ├── activation.py # 将 HTTP 调用隔离:专职负责 /api/v1/activate 请求
│ ├── heartbeat.py # 定时服务调度(依托 threading.Thread)和心跳接口
│ ├── logger.py # 适配 Python 原生 logging 的单例构造器
│ └── exceptions.py # SDK 中抛出的所有专有异常定义(如网络超时、无授权等)
├── tests/ # 测试代码集 (Pytest 等框架)
│ ├── test_hardware.py
│ ├── test_validator.py
│ └── ...
├── examples/ # 对客户公开的应用集成示例
│ └── basic/
│ └── main.py # 照搬 Go 版 demo 逻辑的 Python 实现
├── requirements.txt # 运行必须的核心第三方依赖 (如 requests, cryptography)
├── setup.py # 模块构建、打包配置文件(可选也可使用 pyproject.toml)
├── .gitignore # Git 提交忽略配置文件
└── README.md # 针对 Python 版本的接口快速使用说明
```
## Python 设计与 Go 的核心差异
1. **统一入口**:所有模块收拢在合规的包名 `license_manager` 下,用户只需在外部调用 `import license_manager as license`,然后使用 `license.Client(cfg)`。
2. **并发与定时任务**:相较于 Go 语言轻量级的 Goroutine 处理后台心跳和回调触发,Python 需要通过启动后台 Daemon 线程 `threading.Thread` 进行心跳轮询,并在进行锁处理或休眠时关注 GIL 和中断异常捕获结构。
3. **日志框架对接**:Python 提供更系统完整的 `logging` 模块机制,在此不一定需要过多自定义,只需注入正确的 `logger` 组件即可。
+61
View File
@@ -0,0 +1,61 @@
import time
import logging
from license_manager.client import Client, ClientOptions, Callbacks
from license_manager.config import Config
from license_manager.models import LicensePayload
def main():
logging.basicConfig(level=logging.INFO)
cfg = Config(
server="https://license.example.com",
product="edge-gateway",
version="2.3.1",
authorization_code_path="license_code/authorization_code.txt",
public_key_path="license_code/rsa_public_key.pem",
hardware_fields=["cpu", "memory", "mac"],
heartbeat_interval_seconds=10,
offline=False,
)
def on_license_updated(lic: LicensePayload):
logging.info(f"许可证更新,将于 {lic.expires_at} 过期")
def on_heartbeat_error(err: Exception):
logging.error(f"心跳异常: {err}")
def on_activation_required(reason: str):
logging.warning(f"需要重新激活: {reason}")
callbacks = Callbacks(
on_license_updated=on_license_updated,
on_heartbeat_error=on_heartbeat_error,
on_activation_required=on_activation_required
)
opts = ClientOptions(callbacks=callbacks)
try:
# Client initialization validates configuration, gathers hardware fingerprint,
# performs offline local validation or online activation, and spins up heartbeat threads
client = Client(cfg, opts)
except Exception as e:
logging.error(f"初始化 SDK 失败: {e}")
return
lic = client.current_license()
if lic:
logging.info(f"状态={lic.status} 过期={lic.expires_at}")
logging.info(f"最大激活={lic.max_activations} key={lic.license_key}")
logging.info("主线程执行日常业务任务中...")
try:
time.sleep(35)
except KeyboardInterrupt:
pass
finally:
logging.info("关闭 SDK 客户端以终止后台心跳.")
client.close()
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
# 机器视觉软件授权管理系统 Demo设计文档
客户要求我们做一个测试demo,调用python sdk,模拟产品端实现授权对接。该 Demo 需要桌面端运行。
本设计基于针对“机器视觉应用软件”客户提炼出的核心商业需求和接入流程,通过一个界面直观地向客户呈现 SDK (全自动脱机/在线混合鉴权、SaaS平台设备管控、后台静默心跳)的实际运行效果。
---
## 1. 技术栈选择
- **UI 框架**: `PyQt5` 或 `PySide6`(机器视觉领域的工程师最为熟悉的跨平台桌面 UI 框架之一,也可以用 `tkinter` 以保证最小化无依赖运行)。
- **SDK 层**: `license-manager-sdk-python`(本地调用的核心依赖)。
- **打包工具**: `PyInstaller` (预期输出:可验证交付的独立 `.exe` 执行文件)。
## 2. 界面设计布局 (UI)
Demo 界面将分为 3 个核心区域,确保授权状态的透明和实时性。
### 区域一:设备识别与状态显示牌 (Top Panel)
- **设备指纹 (Hardware ID)**: 只读文本框,直观展示底层 `HardwareProvider` 给当前机器生成的唯一 Hash(如:`a8f5c3...`),旁边有一键复制按钮方便客户测试。
- **当前授权状态**: 醒目的红/绿状态灯及文字(未激活 / 试用中 / 已授权 / 授权已过期 或 被封禁)。
- **到期时间**: 倒计时或日期字符串显示。
- **设备额度使用状况**: 如 `Max Activations: 3` 等透传字段。
### 区域二:授权码输入与操作区 (Middle Panel)
- **产品激活码输入框**: 支持长字符串输入(提示用户可以输入纯 `LIC-xxxx` 在线激活码,也可以输入带 `&` 的完整离线鉴权码)。
- **激活/验证 按钮**:
- 点击后,首先利用逻辑判断是否含有 `&` 进行分流。
- 含有 `&`:提取后半段作为离线证书写入磁盘,触发 `Client` 本地验证,成功后立即变绿。
- 不含 `&`:触发 `Client` 在线激活 (`Activate`)。
- **清理本地许可 按钮 (清空缓存测试用)**: 用于删除 `license.lic` 触发未授权状态,以便于客户在一台机器上反复测试。
### 区域三:业务模拟与底座运行日志 (Bottom Panel)
- **启动机器视觉检测 (模拟按钮)**:
- 每次点击前判断 `client.validate()`。如果在授权期内容,弹出“成功启动 AI 模板检测”弹窗;如果鉴权失败或心跳回调判定锁定,弹出阻挡警告。
- **心跳/运行控制台日志 (Text Area)**:
- 绑定 SDK 的 `logger`。
- 实时打印:初始化成功、读取本地证书、**后台 30分钟(演示可改为30秒) 心跳尝试上报的记录**、网络失败退避的信息。
- 特别监控:打印 `on_activation_required` 钩子的触发日志,向客户演示“一旦服务器封禁,SDK 如何立刻通过回调锁死软件”。
## 3. 核心代码交互逻辑流程
1. **应用启动 (App Init)**
- UI 加载时,立刻实例化 `Client` 并传入一个 `30 秒`(Demo 演示专用)的 `heartbeat_interval_seconds`。
- 配置好两个重要的回调:
- `on_license_updated`: 接收到新证书时间,刷新界面的状态和过期时间。
- `on_activation_required`: 接收到违规被踢下线的消息,强制将“当前授权状态”标红,并置灰“启动机器视觉检测”按钮。
- 尝试执行 `client.current_license()`。有返回值则更新状态并放行,无返回值则提示“请绑定产品激活码”。
2. **激活交互处理 (Activation Workflow)**
- 当用户点击“激活”:
```python
user_input = ui.line_edit_auth_code.text()
if "&" in user_input:
# 脱机侧载演示逻辑
auth_code, envelope = user_input.split("&", 1)
write_envelope_to_local_disk(envelope) # 将离线证书落地
self.init_sdk_client(auth_code) # 使用 SDK 直接拉起脱机验证
else:
# 在线激活演示逻辑
self.init_sdk_client(user_input)
# SDK 将在底层自动跑完 Activate() 网络调用
```
## 4. 给客户演示的核心高光场景 (Showcases)
为获得客户(老板或技术总监)认可,做完 Demo 后建议如下演示:
1. **纯隔离环境发码秒跑测试**:断开本机网络。在输入框粘贴带 `&` 的产品激活码。点击激活,即可看到毫无卡顿地验证通过并允许点击“AI 检测”。**证明离线下发的秒级极佳体验**。
2. **连接网络立刻防盗稽核测试**:重新插上网络。在后台开着 SaaS 管理端(或 Postman 模拟),将这台设备在后台强制踢出/解绑。
3. **心跳钩子秒杀演示**:无需重启 Demo。等待最多 30 秒(Demo 心跳周期),控制台上会立刻打出错误日志,并且瞬间触发 `on_activation_required` 回调,UI 面板变红并拒绝点击核心业务功能。**证明防白嫖/超发管控的有效闭环。**
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,58 @@
This file lists modules PyInstaller was not able to find. This does not
necessarily mean these modules are required for running your program. Both
Python's standard library and 3rd-party Python packages often conditionally
import optional modules, some of which may be available only on certain
platforms.
Types of import:
* top-level: imported at the top-level - look at these first
* conditional: imported within an if-statement
* delayed: imported within a function
* optional: imported within a try-except-statement
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
tracking down the missing module yourself. Thanks!
missing module named pwd - imported by posixpath (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional), netrc (delayed, conditional), getpass (delayed)
missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional)
missing module named _posixsubprocess - imported by subprocess (conditional), multiprocessing.util (delayed)
missing module named fcntl - imported by subprocess (optional)
missing module named _posixshmem - imported by multiprocessing.resource_tracker (conditional), multiprocessing.shared_memory (conditional)
missing module named _scproxy - imported by urllib.request (conditional)
missing module named termios - imported by getpass (optional)
missing module named multiprocessing.BufferTooShort - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.AuthenticationError - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level)
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
missing module named posix - imported by os (conditional, optional), shutil (conditional), importlib._bootstrap_external (conditional), posixpath (optional)
missing module named resource - imported by posix (top-level)
missing module named multiprocessing.get_context - imported by multiprocessing (top-level), multiprocessing.pool (top-level), multiprocessing.managers (top-level), multiprocessing.sharedctypes (top-level)
missing module named multiprocessing.TimeoutError - imported by multiprocessing (top-level), multiprocessing.pool (top-level)
missing module named multiprocessing.set_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named multiprocessing.get_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named pyimod02_importers - imported by C:\Users\42104\AppData\Local\Programs\Python\Python312\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgutil.py (delayed)
missing module named collections.Callable - imported by collections (optional), socks (optional)
missing module named vms_lib - imported by platform (delayed, optional)
missing module named 'java.lang' - imported by platform (delayed, optional)
missing module named java - imported by platform (delayed)
missing module named _winreg - imported by platform (delayed, optional)
missing module named cryptography.x509.UnsupportedExtension - imported by cryptography.x509 (optional), urllib3.contrib.pyopenssl (optional)
missing module named simplejson - imported by requests.compat (conditional, optional)
missing module named 'OpenSSL.crypto' - imported by urllib3.contrib.pyopenssl (delayed, conditional)
missing module named asyncio.DefaultEventLoopPolicy - imported by asyncio (delayed, conditional), asyncio.events (delayed, conditional)
missing module named annotationlib - imported by typing_extensions (conditional)
missing module named OpenSSL - imported by urllib3.contrib.pyopenssl (top-level)
missing module named 'pyodide.ffi' - imported by urllib3.contrib.emscripten.fetch (delayed, optional)
missing module named pyodide - imported by urllib3.contrib.emscripten.fetch (top-level)
missing module named js - imported by urllib3.contrib.emscripten.fetch (top-level)
missing module named 'h2.events' - imported by urllib3.http2.connection (top-level)
missing module named 'h2.connection' - imported by urllib3.http2.connection (top-level)
missing module named h2 - imported by urllib3.http2.connection (top-level)
missing module named zstandard - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named compression - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named brotli - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named brotlicffi - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named dummy_threading - imported by requests.cookies (optional)
missing module named win_inet_pton - imported by socks (conditional, optional)
missing module named chardet - imported by requests (optional)
File diff suppressed because it is too large. Load diff
+315
View File
@@ -0,0 +1,315 @@
import base64
import os
import sys
import threading
import time
import tkinter as tk
from tkinter import messagebox, scrolledtext
# Ensure the SDK root is in PYTHONPATH to allow running demo.py standalone
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")))
from license_manager.client import Client, ClientOptions, Callbacks
from license_manager.config import Config
from license_manager.models import LicensePayload
from license_manager.hardware import DefaultHardwareProvider
class DemoApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("License Manager SDK Demo")
self.geometry("700x650")
self.client = None
# 提取当前设备指纹(仅做界面展示)
hw = DefaultHardwareProvider(["mac", "cpu", "hostname"])
try:
self.fingerprint, _ = hw.fingerprint()
except Exception:
self.fingerprint = "Unknown"
self._build_ui()
self._log("系统初始化完毕,准备加载授权...")
# 延迟100ms去试着加载本地授权
self.after(100, self.init_sdk_offline)
def _build_ui(self):
# 区域一:设备识别与状态显示牌
top_frame = tk.LabelFrame(self, text="设备指纹与当前授权状态", padx=10, pady=10)
top_frame.pack(fill="x", padx=10, pady=5)
tk.Label(top_frame, text="设备指纹 (Hardware ID):").grid(row=0, column=0, sticky="w")
self.entry_fp = tk.Entry(top_frame, width=35)
self.entry_fp.insert(0, self.fingerprint)
self.entry_fp.config(state="readonly")
self.entry_fp.grid(row=0, column=1, padx=5, pady=2)
tk.Button(top_frame, text="复制", command=self._copy_fp).grid(row=0, column=2, padx=5)
tk.Label(top_frame, text="授权状态:").grid(row=1, column=0, sticky="w", pady=(5, 0))
self.lbl_status = tk.Label(top_frame, text="未激活", fg="red", font=("Helvetica", 12, "bold"))
self.lbl_status.grid(row=1, column=1, sticky="w", pady=(5, 0))
tk.Label(top_frame, text="到期时间:").grid(row=2, column=0, sticky="w")
self.lbl_expire = tk.Label(top_frame, text="-")
self.lbl_expire.grid(row=2, column=1, sticky="w")
# 区域二:授权码输入与操作区
mid_frame = tk.LabelFrame(self, text="管控与激活设置", padx=10, pady=10)
mid_frame.pack(fill="x", padx=10, pady=5)
tk.Label(mid_frame, text="SaaS 服务地址:").grid(row=0, column=0, sticky="w", pady=2)
self.entry_server = tk.Entry(mid_frame, width=60)
self.entry_server.insert(0, "http://43.136.177.43:18081")
self.entry_server.grid(row=0, column=1, padx=5, pady=2)
tk.Label(mid_frame, text="RSA 公钥 (PEM):").grid(row=1, column=0, sticky="nw", pady=2)
self.txt_pubkey = scrolledtext.ScrolledText(mid_frame, width=58, height=4, font=("Consolas", 9))
self.txt_pubkey.insert("1.0", (
"-----BEGIN PUBLIC KEY-----\n"
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5hGz3hws+L2MUW7CaRQI\n"
"t7Vxk0KAdPBUuNdkJcs2kpgkDmiT/pHf+9WXfRVdKD/Eu/t7wckQfzziddXTbeCx\n"
"gN/5bKSeLTPXT0VvvSkfB5FUUQABHFWO4fiBlfP/NdFy++qKgCUF1wGg+jSsrhAw\n"
"6oMDI7MHVRfRZTe3rTM3T0qi3Ed6qaoIM9INQ87FcqPVMyQ/KXbe4Dz5yVkLY5iY\n"
"j8ZJP7iU2vKYvWIG9TNv0FK/dXQk7syMGBSKmIncCe+UaflAajbDGXq52gIeTyhO\n"
"BumOLXXBdlwgCVtejBTqDmlQpiJYADKiZLQfx0z1hYbbOPBPB3djXCJJmQk69sso\n"
"ZQIDAQAB\n"
"-----END PUBLIC KEY-----"
))
self.txt_pubkey.grid(row=1, column=1, padx=5, pady=2)
tk.Label(mid_frame, text="产品激活码:").grid(row=2, column=0, sticky="w", pady=2)
self.entry_code = tk.Entry(mid_frame, width=60)
self.entry_code.grid(row=2, column=1, padx=5, pady=2)
btn_frame = tk.Frame(mid_frame)
btn_frame.grid(row=3, column=0, columnspan=2, pady=10, sticky="w")
tk.Button(btn_frame, text="激活 / 验证", bg="#4CAF50", fg="white", width=15, command=self.on_activate_click).pack(side="left", padx=5)
tk.Button(btn_frame, text="清理本地许可缓存", width=15, command=self.on_clear_click).pack(side="left", padx=5)
# 区域三:业务模拟与底座运行日志
bot_frame = tk.LabelFrame(self, text="机器视觉核心业务模拟与日志", padx=10, pady=10)
bot_frame.pack(fill="both", expand=True, padx=10, pady=5)
self.btn_run_ai = tk.Button(bot_frame, text="[业务] 开始 AI 模板检测", bg="#2196F3", fg="white", font=("Helvetica", 14), command=self.on_run_ai_click)
self.btn_run_ai.pack(fill="x", pady=5)
self.txt_log = scrolledtext.ScrolledText(bot_frame, height=10, bg="black", fg="white")
self.txt_log.pack(fill="both", expand=True, pady=5)
def _copy_fp(self):
self.clipboard_clear()
self.clipboard_append(self.fingerprint)
messagebox.showinfo("复制成功", "机器指纹已复制")
def _log(self, msg: str):
now = time.strftime("%H:%M:%S")
self.txt_log.insert(tk.END, f"[{now}] {msg}\n")
self.txt_log.see(tk.END)
def _update_status(self, payload: LicensePayload):
if payload and payload.license_key:
self.lbl_status.config(text="验证通过 (合法)", fg="green")
exp_str = payload.expires_at.strftime("%Y-%m-%d %H:%M:%S") if payload.expires_at else "永久"
self.lbl_expire.config(text=exp_str)
else:
self.lbl_status.config(text="未激活 / 被锁定", fg="red")
self.lbl_expire.config(text="-")
def _safe_update_status(self, payload: LicensePayload):
self.after(0, self._update_status, payload)
def _safe_log(self, msg: str):
self.after(0, self._log, msg)
def _safe_alert(self, title, msg):
self.after(0, lambda: messagebox.showwarning(title, msg))
# --- SDK 钩子定义 ---
def on_license_updated(self, lic: LicensePayload):
self._safe_log(f"-> 钩子触发: [on_license_updated] 下发了有效的新证书!到期: {lic.expires_at}")
self._safe_update_status(lic)
def on_activation_required(self, reason: str):
self._safe_log(f"-> 钩子触发: [on_activation_required] 授权已被驳回/封禁!原因: {reason}")
self.after(0, lambda: self.lbl_status.config(text=f"锁定: {reason}", fg="red"))
self._safe_alert("授权已失效", f"您的授权已被服务器拒绝或锁定,请联系管理员。\n底层原因: {reason}")
def on_heartbeat_error(self, err: Exception):
msg = str(err)
self._safe_log(f"[后台轮询] 心跳无法连接服务器/遭遇异常: {msg}")
# 如果后台返回了明确的 300006 (许可证不存在),说明管理后台删除了授权或该机器被强制解绑
if "300006" in msg:
self._safe_log("[安全策略阻断] 服务端反馈许可不存在!您的授权已被撤销或转移。正在清理本地许可凭证...")
# 因为错误回调是在独立的守护线程里执行的,我们需要借用 after 以 UI 线程安全的方式清理并阻断
self.after(500, self.on_clear_click)
def on_heartbeat_ping(self):
self._safe_log(f"[后台轮询] 成功向服务器上报设备在线心跳!")
# --- 核心业务连贯逻辑 ---
def get_sdk_config(self, auth_code: str = "") -> Config:
server_url = self.entry_server.get().strip()
if not server_url:
server_url = "http://43.136.177.43:18081"
pub_key_str = self.txt_pubkey.get("1.0", tk.END).strip()
pub_key_bytes = pub_key_str.encode('utf-8') if pub_key_str else b""
return Config(
server=server_url,
product="vision-vm-studio",
version="1.0.0",
authorization_code=auth_code,
license_file_path="license.lic",
heartbeat_interval_seconds=10, # 演示专用:10秒一次高频心跳
public_key_pem=pub_key_bytes
)
def init_sdk_offline(self):
"""尝试不带激活码纯本地启动"""
self._log("尝试加载本地 'license.lic' 验证授权...")
cfg = self.get_sdk_config()
# 对于真正从硬盘拉取历史授权记录的“冷启动”:
# 我们不知道用户当时填的激活码是啥,只知道他本地有合法生成的证。
# 为了不破坏联网心跳能力(不设置 cfg.offline),我们塞入一个占位激活码让 Config 通过验证,
# 只要本地有合规授权,SDK Bootstrap 阶段就不会向服务器发起"空行"激活,而是直接拉起心跳。
cfg.authorization_code = "LOADED_FROM_LOCAL_CACHE"
def _boot():
try:
self._init_client_impl(cfg)
self._safe_log("-> 历史本地授权自动加载且验证通过!无需重复输入激活码。")
except Exception as e:
# 初次启动时如果没有激活码和本地授权文件是正常的,这里静默处理
msg = str(e)
if "authorization code" not in msg and "missing" not in msg and "no loaded license" not in msg:
self._safe_log(f"-> 本地授权预加载检测未通过: {msg}")
self.after(0, lambda: self._update_status(None))
threading.Thread(target=_boot, daemon=True).start()
def on_activate_click(self):
user_input = self.entry_code.get().strip()
if not user_input:
messagebox.showwarning("错误", "请输入产品激活码")
return
self._log(f"用户发起激活请求,识别码长度: {len(user_input)}")
cfg = self.get_sdk_config()
envelope_b64 = None
if "&" in user_input:
auth_part, envelope_b64 = user_input.split("&", 1)
cfg.authorization_code = auth_part
self._log("-> 识别到产品激活码中包含脱机包验证参数。")
else:
cfg.authorization_code = user_input
self._log("-> 识别为纯在线授权码。")
# 异步启动防止卡死 UI
threading.Thread(target=self._activate_logic_thread, args=(cfg, envelope_b64), daemon=True).start()
def _activate_logic_thread(self, cfg: Config, envelope_b64: str):
# 先清理本地遗留许可,强制优先走“在线激活”流程
if os.path.exists(cfg.license_file_path):
os.remove(cfg.license_file_path)
self._safe_log("-> [策略] 优先尝试连接 SaaS 平台进行在线激活/绑定...")
try:
self._init_client_impl(cfg)
self._safe_log("-> 在线激活成功!设备已绑定并在后台可见。")
return
except Exception as e:
msg = str(e)
self._safe_log(f"-> 在线激活遭遇异常/网络不通: {msg}")
# 如果存在脱机包,则降级为离线侧载
if envelope_b64:
self._safe_log("-> [策略] 检测到网络无法完成激活,正在自动降级使用脱机包进行完全物理隔离验证...")
try:
payload_bytes = base64.b64decode(envelope_b64)
with open(cfg.license_file_path, "wb") as f:
f.write(payload_bytes)
self._safe_log("-> 脱机包落地成功!正在二次验证...")
self._init_client_impl(cfg)
self._safe_log("-> 脱机验证成功!(系统此时完全离线运行)")
except Exception as ex:
self._safe_log(f"-> 脱机包降级验证失败: {ex}")
self.after(0, lambda: self._update_status(None))
else:
self._safe_log("-> 致命错误:因为没有脱机包且当前无法在线激活,授权流程被阻断。")
self.after(0, lambda: self._update_status(None))
def _init_client_impl(self, cfg: Config):
# 内部真实初始化 Client
# 实战中不建议这么做,此处为了 Demo 演示快节奏,强行忽略后台 SaaS 返回的 600s/3600s 下发间隔限制
from license_manager.models import HeartbeatResponse
original_from_dict = HeartbeatResponse.from_dict
def patched_from_dict(data: dict) -> HeartbeatResponse:
resp = original_from_dict(data)
resp.heartbeat_interval = 10 # 强制覆盖服务端配置
return resp
HeartbeatResponse.from_dict = staticmethod(patched_from_dict)
# 若是外部调用跑进此函数发生异常,会向外抛出被 _activate_logic_thread 捕获
if self.client:
self.client.close()
opts = ClientOptions(
callbacks=Callbacks(
on_license_updated=self.on_license_updated,
on_activation_required=self.on_activation_required,
on_heartbeat_error=self.on_heartbeat_error,
on_heartbeat_ping=self.on_heartbeat_ping
)
)
self.client = Client(cfg, opts=opts)
lic = self.client.current_license()
if lic:
self._safe_log(">>> SDK 验证成功 <<<")
self._safe_update_status(lic)
else:
self.after(0, lambda: self._update_status(None))
def on_clear_click(self):
if self.client:
self.client.close()
self.client = None
if os.path.exists("license.lic"):
os.remove("license.lic")
self._log("已物理删除 'license.lic' 缓存文件。")
else:
self._log("未发现 'license.lic' 文件。")
self._update_status(None)
def on_run_ai_click(self):
if not self.client:
messagebox.showerror("阻断", "SDK 未初始化,请先提供激活码!")
return
# 实时验证
try:
self.client.validate()
# 通过后模拟业务逻辑
self._log("【业务端】实时合规性检测: 通过。")
messagebox.showinfo("业务层", "授权正常!\nAI 模板匹配算法已成功启动,正在处理图像帧...")
except Exception as e:
self._log(f"【业务端】授权拦截: {e}")
messagebox.showerror("越权阻断", f"由于未授权或授权已被封禁,算法引擎拒绝启动。\n\n原因: {e}")
if __name__ == "__main__":
app = DemoApp()
app.mainloop()
@@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['demo.py'],
pathex=['../../..'],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='demo',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+1
View File
@@ -0,0 +1 @@
{"algorithm":"RSA-PSS-SHA256","data":"{\"activated_at\":\"2026-02-27T01:11:00.938011568+08:00\",\"authorization_code\":\"LIC-207E-tEOWEY54X3tL-zSAA\",\"authorization_code_id\":\"939569ef-38c6-401a-8e26-7cf00e225ccd\",\"config_updated_at\":null,\"deployment_type\":\"cloud\",\"end_date\":\"3026-02-25T00:00:00+08:00\",\"generated_at\":\"2026-02-27T01:11:00+08:00\",\"hardware_fingerprint\":\"48aaf5fbd3aa4056462b94d30c38c83991acd46ae3631f0958c12432f862018b\",\"license_key\":\"LIC-DEVICE-E3CB989494260205500B37A3\",\"max_activations\":2,\"start_date\":\"2026-02-25T00:00:00+08:00\",\"status\":\"active\",\"usage_limits\":{\"type\":\"default\"}}","signature":"E+fFyhPa4b4UAPuBeTdctDINkG013f4hRbNhwaKPjMDUi7fLaPGxBgke7BnWLCt6fpWS3thTIv9HjhC6lpDzQe92Gkm/Suk52424zrVVkvjENvF7LopK0PMBpBA4Yx1zkbHwftxXg3Y0olqNakGXh8em9imGbH9AwTMMnAia9aCSN4dXqfgzWkfm0Nt+OynKnCDBijkOwNi6yToctSNO02t3+xQiBXmDut9H+3RqCNIESSsg9+4ILVBi76xpuYAxyEUksDnTFUu365NTmbeuEqLkYQtk0Pr3KE78xGBNd9AWAg5FTMn4A7pNKoo62GfWt7aSDIbgSzVj+Es1JtNZXw=="}
@@ -0,0 +1 @@
{"algorithm":"RSA-PSS-SHA256","data":"{\"activated_at\":\"2026-02-27T01:08:30.548730878+08:00\",\"authorization_code\":\"LIC-207E-tEOWEY54X3tL-zSAA\",\"authorization_code_id\":\"939569ef-38c6-401a-8e26-7cf00e225ccd\",\"config_updated_at\":null,\"deployment_type\":\"cloud\",\"end_date\":\"3026-02-25T00:00:00+08:00\",\"generated_at\":\"2026-02-27T01:08:30+08:00\",\"hardware_fingerprint\":\"48aaf5fbd3aa4056462b94d30c38c83991acd46ae3631f0958c12432f862018b\",\"license_key\":\"LIC-DEVICE-E3CB989494260205500B37A3\",\"max_activations\":2,\"start_date\":\"2026-02-25T00:00:00+08:00\",\"status\":\"active\",\"usage_limits\":{\"type\":\"default\"}}","signature":"WgnPQyNBaSvroZEuk4yy3IGXWCiIGBF9ADBHW24Sl61D0Jc5a9q73UiuQRu012cgApC4fKV+JeRft6msxUtsn0Dw39057N7mK196H8uIjWJ7Xy1rjC6Aceu/kD+BCRZ6K9t7dfnWWOGzz6ixljnsgfLmK/cDalhjpdopZHN1lr8TNUIIlQ/JKRMYr4HFgXlywbTW01/rkuZp6AYgSyZvxkNJW/TjdOCrncTkr4/LnkfJLaeqJ56P9PH1PEnDba5azzOH3OB9y2nN6+HsuIzOe78yxzTr+Nk9tVqTOGIbDOr9Ps2RPC/s6u8ot80frPJ1zprPWck7ZxAeTY82+1TUTw=="}
@@ -0,0 +1,75 @@
# 产品激活码解析与使用说明
本文档面向客户端开发人员,旨在说明 SaaS 平台发放给最终用户的“产品激活码”的数据结构及其在 SDK 中的鉴权原理。
## 什么是产品激活码?
用户在 SaaS 管理端购买或获取的凭证通常被称为**“产品激活码” (Product Activation Code)**。
为了满足工业场景中**纯离线/脱机**的鉴权需求,平台生成的产品激活码不仅仅是一个简单的随机字符串,它实际上是一个**复合字符串**,包含了在线激活所需的凭证以及离线放行所需的鉴权信息。
## 数据结构原理
产品激活码使用 `&` 符号作为分隔符,分为两部分:
```text
[授权码 (Authorization Code)] & [离线鉴权信息 (License Envelope Base64)]
```
### 示例解析
假设用户输入了以下产品激活码:
`LIC-207E-tEOWEY54X3tL-zSAA&eyJhbGdv... (后略)`
1. **`&` 之前的部分:授权码 (Authorization Code)**
- **内容**: `LIC-207E-tEOWEY54X3tL-zSAA`
- **作用**: 这是用于**在线激活**的凭证。当终端设备处于**联网**状态时,客户端 SDK 的 `Activate` 接口会使用此字符串向服务端发起请求。服务端会进行校验,扣减装机配额,并与该机器的硬件指纹进行绑定,最终全自动下发专属于这台机器的许可证文件。
2. **`&` 之后的部分:鉴权信息 (Authentication Information)**
- **内容**: `eyJhbGdv...` (这是一个经过 Base64 编码的 JSON 字符串,即 `License Envelope`)
- **作用**: 这是用于**离线验证**的凭证包。当终端设备处于**完全无网/隔离部署**的状态,无法连接到远程 SaaS 平台完成激活时,这就是客户端的“越权通行证”。这个 Base64 结构内部包含了被服务端 RSA 私钥数字签名过的数据(如过期时间、最高并发台数等配额信息)。
## 客户端接入与解析伪代码
客户端开发者在获取到用户框输入的“产品激活码”时,只需要做一个简单的字符串分割处理,将其分离为在线激活部分和离线兜底部分,再交给 SDK。
```python
import base64
import os
from license_manager.client import Client, ClientOptions
from license_manager.config import Config
# 这是用户填入的产品激活码
product_activation_code = "LIC-xxxx&eyJhbGdo..."
# 默认将其视作 Auth_Code
auth_code = product_activation_code
offline_auth_data = None
# 【第一步:解析分离】判断是否存在离线鉴权信息
if "&" in product_activation_code:
parts = product_activation_code.split("&", 1)
auth_code = parts[0] # 真正的在线授权码
offline_auth_data = parts[1] # 离线鉴权数据 (Envelope)
cfg = Config(
server="https://xxx.com",
product="you_product",
version="1.0.0",
authorization_code=auth_code, # 将分离出的 Auth_Code 交给 SDK
)
# 【第二步:离线侧载】如果存在离线数据,且本地尚无合法的证书,直接将其落地
if offline_auth_data:
# 直接将 SaaS 发放的公版离线鉴权包保存到目标证书路径
# SDK 初始化时会自动读取、验签、生效,从而实现"物理断网"状态下的秒级试用体验
with open(cfg.license_file_path, "wb") as f:
f.write(base64.b64decode(offline_auth_data))
# 【第三步:启动 SDK】
# SDK 会自动优先寻找本地证书(即我们刚刚侧载进去的离线鉴权信息)
# 若本地校验合法将直接启动;若机器具备网络环境,自带的 Heartbeat 还会自动联网嗅探对齐状态!
client_facade = Client(cfg, opts=ClientOptions(...))
```
## 运行机制总结
这种 `授权码 & 鉴权信息` 的二合一产品激活码设计,极大降低了对用户的解释成本。用户只需复制这一个字符串,无论他们身处有网络的办公室还是物理隔离的车间产线,开发者开发的软件结合 License SDK 都可以做到“离线直接跑、在线强管控”。
@@ -0,0 +1,128 @@
# 客户案例:机器视觉检测软件的灵活授权方案
## 客户背景
客户研发了一款基于深度学习技术的高性能机器视觉应用软件。该软件专为工业制造场景设计,能够准确识别工件表面的细小划痕、斑点,并精确检测工件的形态和轮廓缺陷,可有效克服复杂纹理、毛刺、颜色和各种噪点的干扰。此外,产品内置了 VM 算法开发平台,提供图像分割、分类、模板检测、字符定位与识别、异常检测等算法模块,支持独立训练工具以高效完成打标和训练。
**商业模式:**
- 软件单设备许可售价约为 300 元,定价亲民。
- 由于现代工业环境大部分已经实现联网,且目标客户群体(工厂、集成商等)大多具有付费能力,客户希望通过极具吸引力的“免费体验”策略来进行广泛的产品线宣传。
- 同时客户希望建立一套完整的生态,通过自服务的 **SaaS 授权管理平台**,直接完成交易、授权发放、设备分配甚至额度流转功能。
## 核心需求与业务痛点
1. **具有时效性的免费下载试用**:允许用户免费下载获取试用**产品激活码**,但该验证仅限定在每个月的 **1号 至 25号** 之间才能被验证使用。
2. **多终端授权统管与自助解绑机制**:
- 用户在 SaaS 用户端可以直接线上购买**产品激活码**,**一个产品激活码可包含多个设备数量许可**。
- 可以在规定的终端许可数量内激活不同机台。对于已激活的设备,**若绑定时间不满 3 个月,允许用户在客户端/SaaS端自助解绑以更换老旧机器**;若绑定超过 3 个月,强制转入人工审计,必须联系官方客服解绑。
- 未使用的授权额度甚至 **可以由用户转让给其他用户**,这一特性变相也成为了一种社交化裂变宣传的方式。
3. **彻底的断网可用性支持 (Offline Side-loading)**:
- 工业环境中存在纯隔离内网,客户需要**即便在完全不联网的机器上也能立即使用产品激活码**完成试用和投产。
- **巧妙的离线鉴权设计**:用户在管理平台拿到的是包含授权与鉴权的**产品激活码**。对于无网环境,激活码采用特殊格式:在纯授权码后拼接 `&` 及一个 Base64 编码的签名数据(即鉴权信息:完整的许可证 Envelope),形如:
`LIC-207E-tEOWEY54X3tL-zSAA&eyJhbGdv...`
- 当终端软件完全无法联网进行 `Activate` 获取凭证时,要求软件能自动截取 `&` 后的鉴权信息,当作合法的离线许可证,直接通过公钥鉴权放行。
4. **一旦联网即刻校验的混合模型**:优先保证离线可用;但**一旦侦测到设备联网,就必须立刻触发在线激活和心跳上报机制**。如果服务端判定该激活状态存在异常(如已被官方封禁、吊销、超出装机数),需强制清除本地授权并要求重新输入新的**产品激活码**。
5. **轻量接入(业务侵入低)**:客户原业务流程为:“程序打开后调用一次授权检测(返回 OK/NG及对应信息),然后程序内设有一个独立线程,每隔 10/30 分钟做一次授权检测。程序里支持输入**产品激活码**执行在线/离线激活。”客户希望这套机制不仅能被满足,还要开发门槛极低。
## 解决方案设计
基于 `license-manager-sdk` 及配合 SaaS 管理端,我们为客户设计了无缝接入的如下体系:
1. **时效及并发管控交由平台**:利用 License Manager 服务端后台,可以轻松发行为“一码多端”赋能的带有指定有效期(如自然月 1-25日)的复合**产品激活码**,且配合解绑策略 API 完美实现 3 个月审计风控。
2. **“开箱即用”的高级功能封装**:
- **指纹绑定防篡改**:SDK 内建的 `HardwareProvider` 会自动提取设备指纹(MAC/CPU/内存),在线下发许可证或离线颁发时均强制与该硬件绑定,阻断倒卖。
- **灵活的脱机解析 (Offline Side-loading)**:对于含有 `&` 和离线数据的后缀,开发者仅需在获取到用户输入字符串时做一个简单的 `split("&")`。如果存在第二部分,则直接将其传入 SDK 存储层 `Store.save(data)` 落盘。后续直接调用 `Validate`,SDK 内部无状态的 RSA `Validator` 即刻放行,从而在**物理网线拔除的状态下,软件也能顺利投产**。
3. **“先离线侧载,一旦联网则握手对齐”与混合心跳策略 (Client Facade)**:
- **启动阶段**:无论前置条件如何,调用 `NewClient` 加密恢复本地缓存并尝试 `Validate`。如果是通过 `&` 方式侧载进去的凭据,这一步将秒解脱机痛点。
- **后台轮询与联网嗅探**:对于“每隔 10/30 分钟轮询”的任务,初始化 `Client` 时启动 SDK 防阻塞心跳即可。当机器意外接入互联网时,心跳请求将送达服务端。如果此时服务端审查出该离线证存在套用、超额、甚至违规转让的情况,服务将拒绝心跳并下达指令。
4. **状态回调与异常自清理机制 (Callbacks)**:
- 利用 `on_activation_required` 钩子,当后台心跳侦测到服务端因为状态异常拒绝提供新票据时,钩子实时触发。客户只需在此处弹出一个悬浮窗,清除主界面逻辑即可,形成从脱机到联网的完美威慑闭环。
## 实施过程示例
基于新增的纯离线后缀导入要求,客户的初始化代码(Python 版示意)只需添加极少量几行处理逻辑即可兼容两大场景:
```python
import base64
from license_manager.client import Client, ClientOptions, Callbacks
from license_manager.config import Config
# 第一步:定义 SDK 后台处理的独立回调事件
def on_license_updated(lic):
print("后台心跳成功接通服务器,不仅保持了在线监控,还顺延了过期时间。")
def on_activation_required(reason):
print(f"警告:心跳发现此授权被封禁(可能是超额或异地重发),立即锁死软件UI!原因: {reason}")
# 强制弹出要求输入授权码及在线激活的 UI 界面,或者强制软件退出
callbacks = Callbacks(
on_license_updated=on_license_updated,
on_activation_required=on_activation_required
)
# 模拟用户在安装程序框填入的“产品激活码”(带 & 后缀包含鉴权信息)
user_input = "LIC-207E-tEOWEY54X3tL-zSAA&eyJhbGdv..."
product_activation_code = user_input
auth_code = product_activation_code
offline_envelope_payload = None
if "&" in product_activation_code:
# 利用产品激活码的特性剥离纯在线授权码与离线鉴权 JSON 包
parts = product_activation_code.split("&", 1)
auth_code = parts[0]
offline_envelope_payload = parts[1]
# 第二步:配置基础信息
cfg = Config(
server="https://customers-license-server.example.com",
product="vision-vm-studio",
version="1.0.0",
authorization_code=auth_code,
heartbeat_interval_seconds=1800, # 配合客户要求 30 分钟轮询
)
# 如果有离线 Envelope 侧载包(且当前本地尚无 license),将其预先存入文件
opts = ClientOptions(callbacks=callbacks)
client_facade = None
try:
if offline_envelope_payload: # 当场验证
# 在正式初始化前,预先落盘离线凭证包
import os
from license_manager.storage import FileStore
import json
# 为了演示,通过简单 decode 处理获取裸的数据落盘
# (这里客户可在初始化前直接覆写 config 中的 target 文件)
with open(cfg.license_file_path, "wb") as f:
f.write(base64.b64decode(offline_envelope_payload))
# 第三步:实例化客户端(硬件提取、本地验签恢复、以及心跳线程启动 自动完成)
client_facade = Client(cfg, opts=opts)
# 获取详细授权,展示UI
current_lic = client_facade.current_license()
print(f"初次打开授权检测:OK - 可以进入机器视觉主界面开展检测任务 (最大同时终端数: {current_lic.extras.get('max_activations', 1)})")
except Exception as e:
print(f"初次打开授权检测:NG - 未检测到合法授权或脱机鉴权失败: {e}")
```
## 客户反馈
客户研发负责人在发版后给出了极高的评价:
> “引入 SDK 和 SaaS 管理端之后,我们实现了授权码的一码多端支持以及极为巧妙的用户配额转让与自助解绑制度。**这一套 3 个月的自助风控简直极大减轻了我们客服的压力,而用户对自由转让闲余设备额度也表现出了浓厚的兴趣。**最棒的一点是,针对纯离线的厂区机器,那段拼接了 `&` 和签名 JSON 的魔术字符串直接绕开了传统必须让用户提报硬件机器码、等我们生成许可并人工发邮件回去对拷证书的繁琐。直接复制粘贴,秒下场干活脱机鉴定——一旦设备联网违规,客户端拦截也能立即锁喉,这真正做到了防白嫖和获客的平衡。”
## 设计哲学与防破解的现实主义思考 (深度剖析)
面对这样一个允许“脱机鉴权”又允许“白嫖试用”、定价仅为 300 元左右的混合验证方案,很多开发者第一直觉是:**“如果有人永远彻底断网运行这个软件,然后伪造机器特征去刷带 `&` 的激活码包,它防得住百分百的破解和白嫖吗?”**
答案是防不住绝对的黑客,但**在此业务场景下,这正是客户极其高明且克制的架构考量,它完全契合实际的工业化商业运作逻辑:**
1. **绝对离线环境在当代工业界的假象化**:
- 绝大多数工厂的机台,尤其是应用机器视觉这种需要上传质检报表并与 MES (制造执行系统) / ERP 打通的机器,**必然要连接厂内局域网或外部专网**。
- 这意味着设备长期处于“绝对物理隔离且无网”的概率越来越低。一旦联网,心跳钩子会在几分钟内抓取异常许可并锁定。
2. **“防君子不防小人”的用户心理博弈**:
- 企业级用户(特别是工厂买办和系统集成商)的心理是:只要官方给出一个清晰、名正言顺的购买和核查渠道,且价格(300元)远低于找破解版所承担的法律风险、版本维护成本和潜在勒索病毒风险,绝大多数企业极度乐于花这笔“小财”买个官方的正品安心。
- 商业软件保护的终极目的**从来不是杜绝所有逆向工程,而是极大地提高白嫖的门槛和心理成本**,促使 95% 以上具备支付能力的人觉得“花 300 块钱买个合法授权和转让自由,比到处找断网机刷盗版划算太多了”。
3. **“漏洞”本身就是极佳的病毒式营销**:
- 客户这种带有转让属性、时效性的策略,在某种程度上甚至是“刻意留有余地”。小规模的破解者或是教育机即便破解了不联网使用,反而极大增加了该软件在算法工程师和底层工区内的市占率和习惯依赖。
- 一旦这些人走上带有大规模生产网络的成熟产线接下商业订单,他们最先采购的,必然是此时用得最顺手的正版授权。
## 总结
此机器视觉案例充分证实了采用 SDK 与 License Manger 平台组合带来的威力:它不仅实现了精细至季度的设备生命周期管控,更是实现了对线下物理隔离场景的前卫处理。通过巧妙拼接带签名的包围体,充分运用 SDK 本地的 RSA Validator 来达到无需任何多重校验的离线“秒放行”,为工业场景的软件授权探索出了低侵入但高防范的实施样板。真正做到了以商业效率为先导,而非陷入无效“自嗨式反破解”的泥潭。
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+56
View File
@@ -0,0 +1,56 @@
import json
from typing import Dict, Optional
import requests
from requests.exceptions import RequestException
from license_manager.config import Config
from license_manager.models import ActivateRequest, ActivateResponse
class ActivationService:
def __init__(self, cfg: Config):
base = cfg.server.rstrip("/")
if cfg.base_path:
base = f"{base}/{cfg.base_path.strip('/')}"
self.base_url = base
self.headers = cfg.http_headers or {}
self.timeout = cfg.http_timeout_or_default()
self.session = requests.Session()
self.session.headers.update({"Content-Type": "application/json"})
self.session.headers.update(self.headers)
def activate(self, req: ActivateRequest) -> ActivateResponse:
url = f"{self.base_url}/api/v1/activate"
payload = req.to_dict()
try:
resp = self.session.post(url, json=payload, timeout=self.timeout)
except RequestException as e:
raise ValueError(f"activation: network error: {e}")
try:
resp_data = resp.json()
except ValueError:
raise ValueError(f"activation: decode response failed, status {resp.status_code}")
if resp.status_code >= 300:
if isinstance(resp_data, dict) and "message" in resp_data:
msg = resp_data.get("message", "")
code = resp_data.get("code", "")
raise ValueError(f"activation: {msg} ({code})")
raise ValueError(f"activation: status {resp.status_code}")
code = resp_data.get("code", "")
message = resp_data.get("message", "")
if code and str(code) != "000000":
raise ValueError(f"activation: {message} ({code})")
data = resp_data.get("data")
if not data:
raise ValueError("activation: response missing data")
return ActivateResponse.from_dict(data)
+264
View File
@@ -0,0 +1,264 @@
import base64
import json
import logging
import threading
from datetime import datetime, timezone
from typing import Callable, Optional
from license_manager.activation import ActivationService
from license_manager.config import Config
from license_manager.hardware import DefaultHardwareProvider, HardwareProvider
from license_manager.heartbeat import HeartbeatManager, HeartbeatService
from license_manager.models import ActivateRequest, HeartbeatRequest, HeartbeatResponse, LicensePayload
from license_manager.storage import FileStore, Store
from license_manager.validator import Validator
class Callbacks:
def __init__(
self,
on_license_updated: Optional[Callable[[LicensePayload], None]] = None,
on_heartbeat_error: Optional[Callable[[Exception], None]] = None,
on_activation_required: Optional[Callable[[str], None]] = None,
on_heartbeat_ping: Optional[Callable[[], None]] = None
):
self.on_license_updated = on_license_updated
self.on_heartbeat_error = on_heartbeat_error
self.on_activation_required = on_activation_required
self.on_heartbeat_ping = on_heartbeat_ping
class ClientOptions:
def __init__(
self,
hardware_provider: Optional[HardwareProvider] = None,
store: Optional[Store] = None,
callbacks: Optional[Callbacks] = None
):
self.hardware_provider = hardware_provider
self.store = store
self.callbacks = callbacks or Callbacks()
class Client:
def __init__(self, cfg: Config, opts: Optional[ClientOptions] = None):
if not cfg:
raise ValueError("license: config is required")
cfg.validate()
pub_key = cfg.resolve_public_key()
self.cfg = cfg
opts = opts or ClientOptions()
self.store = opts.store or FileStore(cfg.storage_path, cfg.storage_secret)
self.hardware = opts.hardware_provider or DefaultHardwareProvider(cfg.hardware_fields)
self.callbacks = opts.callbacks
self.validator = Validator(pub_key)
self.activate_svc = ActivationService(cfg)
self.heartbeat_svc = HeartbeatService(cfg)
self.heartbeat_manager: Optional[HeartbeatManager] = None
try:
fp, details = self.hardware.fingerprint()
self.fingerprint = fp
self.fp_details = details
except Exception as e:
raise ValueError(f"license: collect fingerprint: {e}")
self.license_key = ""
self.current: Optional[LicensePayload] = None
self._lock = threading.RLock()
self._bootstrap()
def validate(self) -> None:
"""Re-validates the current license."""
with self._lock:
lic = self.current
if not lic:
raise ValueError("license: no loaded license")
if lic.expires_at:
now = datetime.now(timezone.utc)
exp = lic.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if now > exp:
raise ValueError("license: expired")
def current_license(self) -> Optional[LicensePayload]:
"""Returns a copy of the payload."""
with self._lock:
if not self.current:
return None
# Return a shallow copy is usually enough for python dataclass
# but standard deepcopy would be strictly safer if user modifies dicts inside
import copy
return copy.deepcopy(self.current)
def pause_heartbeat(self):
"""Temporarily halts heartbeat loop."""
if self.heartbeat_manager:
self.heartbeat_manager.pause()
def resume_heartbeat(self):
"""Resumes heartbeat loop."""
if self.heartbeat_manager:
self.heartbeat_manager.resume()
def close(self):
"""Shuts down background threads."""
if self.heartbeat_manager:
self.heartbeat_manager.stop()
def _bootstrap(self):
self._load_existing()
if self.current:
if not self.cfg.offline:
self._start_heartbeat()
return
if self.cfg.offline:
raise ValueError("license: offline mode requires preloaded license")
self._perform_activation()
self._start_heartbeat()
def _load_existing(self):
try:
data = self.store.load()
if not data:
return
payload = self._validate_and_store(data)
with self._lock:
self.current = payload
self.license_key = payload.license_key
except Exception:
pass
def _perform_activation(self):
device_info = dict(self.cfg.device_info or {})
if self.fp_details:
device_info["hardware"] = self.fp_details
req = ActivateRequest(
authorization_code=self.cfg.authorization_code,
product=self.cfg.product,
version=self.cfg.version,
hardware_fingerprint=self.fingerprint,
device_info=device_info,
metadata=self.cfg.metadata
)
try:
resp = self.activate_svc.activate(req)
except Exception as e:
if self.callbacks.on_activation_required:
try:
self.callbacks.on_activation_required(str(e))
except Exception:
pass
raise
if not resp.license_file:
raise ValueError("license: activation response missing license file")
payload = self._apply_license_file(resp.license_file)
with self._lock:
self.license_key = resp.license_key
def _apply_license_file(self, base64_file: str) -> LicensePayload:
try:
raw = base64.b64decode(base64_file)
except Exception as e:
raise ValueError(f"license: decode license file: {e}")
payload = self._validate_and_store(raw)
with self._lock:
self.current = payload
if self.callbacks.on_license_updated:
threading.Thread(target=self.callbacks.on_license_updated, args=(payload,), daemon=True).start()
return payload
def _validate_and_store(self, raw: bytes) -> LicensePayload:
normalized = self._normalize_license_bytes(raw)
payload = self.validator.verify(normalized, self.fingerprint)
try:
self.store.save(normalized)
except Exception as e:
raise ValueError(f"license: persist license: {e}")
return payload
def _start_heartbeat(self):
with self._lock:
license_key = self.license_key
if not license_key:
return
req = HeartbeatRequest(
license_key=license_key,
hardware_fingerprint=self.fingerprint
)
interval = self.cfg.heartbeat_interval_or_default()
def on_update(resp: HeartbeatResponse):
try:
if resp.license_file:
self._apply_license_file(resp.license_file)
except Exception:
pass
if resp.status == "activation_required" and self.callbacks.on_activation_required:
try:
self.callbacks.on_activation_required("heartbeat requested reactivation")
except Exception:
pass
def on_error(err: Exception):
if self.callbacks.on_heartbeat_error:
try:
self.callbacks.on_heartbeat_error(err)
except Exception:
pass
mgr = HeartbeatManager(
service=self.heartbeat_svc,
req=req,
interval_seconds=interval,
on_license_updated=on_update,
on_error=on_error,
on_ping=self.callbacks.on_heartbeat_ping
)
mgr.start()
self.heartbeat_manager = mgr
def _normalize_license_bytes(self, raw: bytes) -> bytes:
trimmed = raw.strip()
if not trimmed:
raise ValueError("license: empty license file")
if trimmed.startswith(b'{'):
return trimmed
try:
decoded = base64.b64decode(trimmed)
decoded = decoded.strip()
if not decoded or not decoded.startswith(b'{'):
raise ValueError("unsupported structure")
return decoded
except Exception as e:
raise ValueError(f"license: decode base64 license: {e}")
+99
View File
@@ -0,0 +1,99 @@
import os
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any
@dataclass
class Config:
server: str = ""
base_path: str = ""
product: str = ""
version: str = ""
authorization_code: str = ""
authorization_code_path: str = ""
license_file_path: str = ""
public_key_pem: bytes = b""
public_key_path: str = ""
offline: bool = False
heartbeat_interval_seconds: int = 0
http_timeout_seconds: int = 0
log_level: str = "info"
storage_path: str = ""
storage_secret: bytes = b""
hardware_fields: List[str] = field(default_factory=list)
device_info: Dict[str, Any] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
http_headers: Dict[str, str] = field(default_factory=dict)
def validate(self) -> None:
if not self.product:
raise ValueError("config: product is required")
if not self.version:
raise ValueError("config: version is required")
if not self.offline and not self.server:
raise ValueError("config: server is required for online mode")
if not self.offline and not self.authorization_code and not self.authorization_code_path and not self.license_file_path:
raise ValueError("config: authorization code, authorization code path, or license file path is required")
if not self.license_file_path:
self.license_file_path = os.path.join("license_code", "license.lic")
if not self.storage_path:
self.storage_path = self.license_file_path
self.resolve_public_key()
if not self.offline:
self.resolve_authorization_code()
def resolve_public_key(self) -> bytes:
if self.public_key_pem:
return self.public_key_pem
if not self.public_key_path:
raise ValueError("config: public key pem or path must be provided")
try:
with open(self.public_key_path, 'rb') as f:
data = f.read()
self.public_key_pem = data
return data
except Exception as e:
raise ValueError(f"config: read public key: {e}")
def resolve_authorization_code(self) -> str:
if self.authorization_code:
self.authorization_code = self.authorization_code.strip()
return self.authorization_code
if not self.authorization_code_path:
raise ValueError("config: authorization code or path must be provided")
try:
with open(self.authorization_code_path, 'r', encoding='utf-8') as f:
code = f.read().strip()
if not code:
raise ValueError("config: authorization code file is empty")
self.authorization_code = code
return code
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"config: read authorization code: {e}")
def heartbeat_interval_or_default(self) -> int:
if self.heartbeat_interval_seconds > 0:
return self.heartbeat_interval_seconds
return 300 # Default 5 minutes
def http_timeout_or_default(self) -> int:
if self.http_timeout_seconds > 0:
return self.http_timeout_seconds
return 15 # Default 15 seconds
+152
View File
@@ -0,0 +1,152 @@
import hashlib
import json
import logging
import os
import platform
import socket
import subprocess
import uuid
from typing import List, Tuple, Dict, Optional
class HardwareProvider:
"""Abstracts hardware fingerprint collection."""
def fingerprint(self) -> Tuple[str, Dict[str, str]]:
raise NotImplementedError
class DefaultHardwareProvider(HardwareProvider):
"""
Collects host-agnostic identifiers.
Supported field names (case-insensitive):
- mac: first non-loopback MAC address (default)
- hostname: OS host name (default)
- cpu: CPU model / brand string (best-effort)
- memory: total physical memory capacity (best-effort)
"""
def __init__(self, fields: Optional[List[str]] = None):
if not fields:
self.fields = ["mac", "hostname"]
else:
self.fields = [f.strip().lower() for f in fields if f.strip()]
if not self.fields:
self.fields = ["mac", "hostname"]
def fingerprint(self) -> Tuple[str, Dict[str, str]]:
data: Dict[str, str] = {}
for field in self.fields:
if field == "hostname":
data["hostname"] = self._get_hostname()
elif field == "mac":
mac = self._get_mac()
if mac:
data["mac"] = mac
elif field == "cpu":
cpu = self._get_cpu()
if cpu:
data["cpu"] = cpu
elif field == "memory":
mem = self._get_memory()
if mem:
data["memory"] = mem
# Deterministic serialization
keys = sorted(data.keys())
builder = []
for k in keys:
val = str(data[k]).lower()
builder.append(f"{k}={val};")
full_string = "".join(builder)
hash_sum = hashlib.sha256(full_string.encode('utf-8')).hexdigest()
return hash_sum, data
def _get_hostname(self) -> str:
try:
return socket.gethostname()
except Exception:
return "unknown"
def _get_mac(self) -> str:
try:
# uuid.getnode() returns MAC of the first active interface
# or a random 48-bit number if not found (has multicast bit set)
node = uuid.getnode()
if (node >> 40) % 2 == 0: # Not a random MAC
mac = ':'.join(('%012x' % node)[i:i+2] for i in range(0, 12, 2))
return mac.lower()
return ""
except Exception:
return ""
def _get_cpu(self) -> str:
system = platform.system().lower()
try:
if system == "linux":
with open("/proc/cpuinfo", "r") as f:
for line in f:
if line.lower().startswith("model name"):
return line.split(":")[1].strip()
elif system == "darwin":
return self._cmd_output(["sysctl", "-n", "machdep.cpu.brand_string"])
elif system == "windows":
wmic_out = self._cmd_output(["wmic", "cpu", "get", "Name"])
if wmic_out and "name" not in wmic_out.lower():
return wmic_out
# Fallback
env_proc = os.getenv("PROCESSOR_IDENTIFIER")
if env_proc:
return env_proc
except Exception:
pass
return platform.processor() or platform.machine()
def _get_memory(self) -> str:
system = platform.system().lower()
try:
if system == "linux":
with open("/proc/meminfo", "r") as f:
for line in f:
if line.lower().startswith("memtotal"):
parts = line.split()
if len(parts) >= 2:
val_kb = int(parts[1])
return self._format_bytes(val_kb * 1024)
elif system == "darwin":
out = self._cmd_output(["sysctl", "-n", "hw.memsize"])
if out:
return self._format_bytes(int(out))
elif system == "windows":
out = self._cmd_output(["wmic", "computersystem", "get", "TotalPhysicalMemory"])
if out:
return self._format_bytes(int(out))
except Exception:
pass
return ""
def _cmd_output(self, cmd: List[str]) -> str:
try:
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, timeout=2).decode('utf-8')
lines = [line.strip() for line in out.split('\n') if line.strip()]
for line in lines:
lower_line = line.lower()
if "name" in lower_line or "totalphysicalmemory" in lower_line:
continue
return line
return ""
except Exception:
return ""
def _format_bytes(self, b: int) -> str:
kb = 1024
mb = 1024 * kb
gb = 1024 * mb
if b >= gb:
return f"{b // gb}GB"
elif b >= mb:
return f"{b // mb}MB"
elif b >= kb:
return f"{b // kb}KB"
else:
return f"{b}B"
+157
View File
@@ -0,0 +1,157 @@
import json
import logging
import threading
import time
from typing import Callable, Optional
import requests
from requests.exceptions import RequestException
from license_manager.config import Config
from license_manager.models import HeartbeatRequest, HeartbeatResponse
class HeartbeatService:
def __init__(self, cfg: Config):
base = cfg.server.rstrip("/")
if cfg.base_path:
base = f"{base}/{cfg.base_path.strip('/')}"
self.base_url = base
self.headers = cfg.http_headers or {}
self.timeout = cfg.http_timeout_or_default()
self.session = requests.Session()
self.session.headers.update({"Content-Type": "application/json"})
self.session.headers.update(self.headers)
def send(self, req: HeartbeatRequest) -> HeartbeatResponse:
url = f"{self.base_url}/api/v1/heartbeat"
payload = req.to_dict()
try:
resp = self.session.post(url, json=payload, timeout=self.timeout)
except RequestException as e:
raise ValueError(f"heartbeat: network error: {e}")
try:
resp_data = resp.json()
except ValueError:
raise ValueError(f"heartbeat: decode response failed, status {resp.status_code}")
if resp.status_code >= 300:
if isinstance(resp_data, dict) and "message" in resp_data:
msg = resp_data.get("message", "")
code = resp_data.get("code", "")
raise ValueError(f"heartbeat: {msg} ({code})")
raise ValueError(f"heartbeat: status {resp.status_code}")
code = resp_data.get("code", "")
message = resp_data.get("message", "")
if code and str(code) != "000000":
raise ValueError(f"heartbeat: {message} ({code})")
data = resp_data.get("data")
if not data:
raise ValueError("heartbeat: response missing data")
return HeartbeatResponse.from_dict(data)
class HeartbeatManager:
def __init__(
self,
service: HeartbeatService,
req: HeartbeatRequest,
interval_seconds: int,
on_license_updated: Optional[Callable[[HeartbeatResponse], None]] = None,
on_error: Optional[Callable[[Exception], None]] = None,
on_ping: Optional[Callable[[], None]] = None
):
self.service = service
self.req = req
self.interval = interval_seconds
self.on_license_updated = on_license_updated
self.on_error = on_error
self.on_ping = on_ping
self._lock = threading.Lock()
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
self.running = False
self.paused = False
def start(self):
with self._lock:
if self.running:
return
self.running = True
self._stop_event.clear()
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
def stop(self):
self._stop_event.set()
if self._thread and self._thread.is_alive():
# Don't join immediately to avoid blocking main thread on exit if not needed,
# or we could join if we want strictly clean shutdowns.
# self._thread.join(timeout=2)
pass
with self._lock:
self.running = False
def pause(self):
with self._lock:
self.paused = True
def resume(self):
with self._lock:
self.paused = False
def _loop(self):
try:
current_interval = self.interval
while not self._stop_event.is_set():
# wait using stop_event with a timeout, allowing responsive stopping
if self._stop_event.wait(current_interval):
break
with self._lock:
paused = self.paused
if paused:
continue
try:
resp = self.service.send(self.req)
if self.on_ping:
threading.Thread(target=self.on_ping, daemon=True).start()
if resp.heartbeat_interval and resp.heartbeat_interval > 0:
current_interval = resp.heartbeat_interval
else:
current_interval = self.interval
if resp.license_file and self.on_license_updated:
# Call callback in a separate thread so we don't block next heartbeat
threading.Thread(target=self.on_license_updated, args=(resp,), daemon=True).start()
except Exception as e:
current_interval = self._backoff(current_interval)
if self.on_error:
threading.Thread(target=self.on_error, args=(e,), daemon=True).start()
finally:
with self._lock:
self.running = False
def _backoff(self, current: float) -> float:
nxt = current * 2
if nxt > 1800: # 30 minutes max
nxt = 1800
if nxt < 30: # 30 seconds min out of retry
nxt = 30
return nxt
+189
View File
@@ -0,0 +1,189 @@
import base64
import json
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, Optional
@dataclass
class LicensePayload:
license_key: str
authorization_code: str
hardware_fingerprint: str
status: str
start_date: datetime
end_date: datetime
expires_at: datetime
product: Optional[str] = None
version: Optional[str] = None
authorization_code_id: Optional[str] = None
deployment_type: Optional[str] = None
max_activations: Optional[int] = None
custom_parameters: Dict[str, Any] = field(default_factory=dict)
feature_config: Dict[str, Any] = field(default_factory=dict)
usage_limits: Dict[str, Any] = field(default_factory=dict)
extras: Dict[str, Any] = field(default_factory=dict)
activated_at: Optional[datetime] = None
generated_at: Optional[datetime] = None
config_updated_at: Optional[datetime] = None
@classmethod
def from_dict(cls, data: dict) -> "LicensePayload":
def parse_time(val):
if not val:
return None
# Go's time.RFC3339 / ISO 8601 parsing approximation
try:
# Handle Z timezone
if isinstance(val, str) and val.endswith("Z"):
val = val[:-1] + "+00:00"
return datetime.fromisoformat(val)
except Exception:
return None
# expires_at is not explicitly in JSON sometimes, handled differently in Go?
# In Go, ExpiresAt is populated by the validator based on EndDate or similar logic,
# or from a different field. We'll set it to end_date as default parser.
args = {
"license_key": data.get("license_key", ""),
"authorization_code": data.get("authorization_code", ""),
"hardware_fingerprint": data.get("hardware_fingerprint", ""),
"status": data.get("status", ""),
"start_date": parse_time(data.get("start_date")),
"end_date": parse_time(data.get("end_date")),
"product": data.get("product"),
"version": data.get("version"),
"authorization_code_id": data.get("authorization_code_id"),
"deployment_type": data.get("deployment_type"),
"max_activations": data.get("max_activations"),
"custom_parameters": data.get("custom_parameters", {}),
"feature_config": data.get("feature_config", {}),
"usage_limits": data.get("usage_limits", {}),
"extras": data.get("extras", {}),
"activated_at": parse_time(data.get("activated_at")),
"generated_at": parse_time(data.get("generated_at")),
"config_updated_at": parse_time(data.get("config_updated_at")),
}
# In Go ExpiresAt has json:"-" meaning it's internal.
args["expires_at"] = args["end_date"]
return cls(**args)
@dataclass
class LicenseEnvelope:
algorithm: str
data: str
signature: str
@classmethod
def from_dict(cls, data: dict) -> "LicenseEnvelope":
return cls(
algorithm=data.get("algorithm", ""),
data=data.get("data", ""),
signature=data.get("signature", "")
)
@dataclass
class ActivateRequest:
authorization_code: str
product: str
version: str
hardware_fingerprint: str
software_version: Optional[str] = None
device_info: Dict[str, Any] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"authorization_code": self.authorization_code,
"product": self.product,
"version": self.version,
"hardware_fingerprint": self.hardware_fingerprint,
"software_version": self.software_version,
"device_info": self.device_info,
"metadata": self.metadata,
}
@dataclass
class ActivateResponse:
license_key: str
license_file: str
heartbeat_interval: int
payload: Optional[LicensePayload] = None
@classmethod
def from_dict(cls, data: dict) -> "ActivateResponse":
payload_data = data.get("payload")
payload = LicensePayload.from_dict(payload_data) if payload_data else None
return cls(
license_key=data.get("license_key", ""),
license_file=data.get("license_file", ""),
heartbeat_interval=data.get("heartbeat_interval", 0),
payload=payload
)
@dataclass
class HeartbeatRequest:
license_key: str
hardware_fingerprint: str
software_version: Optional[str] = None
usage_data: Dict[str, Any] = field(default_factory=dict)
config_updated_at: Optional[datetime] = None
def to_dict(self) -> dict:
d = {
"license_key": self.license_key,
"hardware_fingerprint": self.hardware_fingerprint,
}
if self.software_version is not None:
d["software_version"] = self.software_version
if self.usage_data:
d["usage_data"] = self.usage_data
if self.config_updated_at:
d["config_updated_at"] = self.config_updated_at.isoformat()
return d
@dataclass
class HeartbeatResponse:
status: str
license_file: Optional[str] = None
heartbeat_interval: Optional[int] = None
config_updated: Optional[bool] = None
payload: Optional[LicensePayload] = None
@classmethod
def from_dict(cls, data: dict) -> "HeartbeatResponse":
payload_data = data.get("payload")
payload = LicensePayload.from_dict(payload_data) if payload_data else None
return cls(
status=data.get("status", ""),
license_file=data.get("license_file"),
heartbeat_interval=data.get("heartbeat_interval"),
config_updated=data.get("config_updated"),
payload=payload
)
@dataclass
class APIError:
code: str
message: str
timestamp: str
@classmethod
def from_dict(cls, data: dict) -> "APIError":
return cls(
code=data.get("code", ""),
message=data.get("message", ""),
timestamp=data.get("timestamp", "")
)
+87
View File
@@ -0,0 +1,87 @@
import base64
import hashlib
import os
from typing import Optional
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class Store:
def save(self, data: bytes) -> None:
raise NotImplementedError
def load(self) -> Optional[bytes]:
raise NotImplementedError
def delete(self) -> None:
raise NotImplementedError
class FileStore(Store):
def __init__(self, path: str, secret: bytes = b""):
self.path = path
self.secret = secret
def save(self, data: bytes) -> None:
payload = self._maybe_encrypt(data)
dir_name = os.path.dirname(self.path)
if dir_name:
os.makedirs(dir_name, exist_ok=True)
tmp_path = self.path + ".tmp"
with open(tmp_path, 'wb') as f:
f.write(payload)
os.replace(tmp_path, self.path)
def load(self) -> Optional[bytes]:
if not os.path.exists(self.path):
return None
try:
with open(self.path, 'rb') as f:
content = f.read()
except Exception:
return None
return self._maybe_decrypt(content)
def delete(self) -> None:
if os.path.exists(self.path):
os.remove(self.path)
def _maybe_encrypt(self, data: bytes) -> bytes:
if not self.secret:
return data
key = hashlib.sha256(self.secret).digest()
aesgcm = AESGCM(key)
nonce = os.urandom(12)
ciphertext = aesgcm.encrypt(nonce, data, None)
# Store as `nonce + ciphertext` in base64 with "enc:" prefix
buf = b"enc:" + base64.b64encode(nonce + ciphertext)
return buf
def _maybe_decrypt(self, data: bytes) -> bytes:
if not self.secret:
return data
prefix = b"enc:"
if data.startswith(prefix):
data = data[len(prefix):]
else:
return data
raw = base64.b64decode(data)
key = hashlib.sha256(self.secret).digest()
aesgcm = AESGCM(key)
nonce = raw[:12]
payload = raw[12:]
try:
return aesgcm.decrypt(nonce, payload, None)
except Exception as e:
raise ValueError(f"storage: decrypt: {e}")
+98
View File
@@ -0,0 +1,98 @@
import base64
import json
from datetime import datetime, timezone
from typing import Optional
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from license_manager.models import LicensePayload, LicenseEnvelope
class Validator:
def __init__(self, pem_bytes: bytes):
if not pem_bytes:
raise ValueError("validator: missing public key")
try:
public_key = load_pem_public_key(pem_bytes)
if not isinstance(public_key, rsa.RSAPublicKey):
raise ValueError("validator: public key is not RSA")
self.pub = public_key
except ValueError as e:
if "not RSA" in str(e):
raise
raise ValueError(f"validator: parse public key: {e}")
def verify(self, envelope_bytes: bytes, fingerprint: str) -> LicensePayload:
try:
env_dict = json.loads(envelope_bytes)
env = LicenseEnvelope.from_dict(env_dict)
except Exception as e:
raise ValueError(f"validator: decode license: {e}")
payload_bytes = env.data.encode('utf-8') if isinstance(env.data, str) else env.data
try:
sig = base64.b64decode(env.signature)
except Exception as e:
raise ValueError(f"validator: decode signature: {e}")
self._verify_signature(env.algorithm.upper(), payload_bytes, sig)
try:
payload_dict = json.loads(payload_bytes)
payload = LicensePayload.from_dict(payload_dict)
payload.extras = payload_dict # Include everything in extras
except Exception as e:
raise ValueError(f"validator: decode payload: {e}")
if payload.end_date:
payload.expires_at = payload.end_date
if fingerprint and payload.hardware_fingerprint and payload.hardware_fingerprint != fingerprint:
raise ValueError("validator: hardware fingerprint mismatch")
if payload.expires_at:
now = datetime.now(timezone.utc)
# Ensure expires_at is timezone-aware for comparison
exp = payload.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if now > exp:
raise ValueError("validator: license expired")
return payload
def _verify_signature(self, algorithm: str, data: bytes, sig: bytes) -> None:
try:
if algorithm in ("", "RSA-SHA256", "RSA-PKCS1V15-SHA256"):
self.pub.verify(
sig,
data,
padding.PKCS1v15(),
hashes.SHA256()
)
elif algorithm == "RSA-PSS-SHA256":
self.pub.verify(
sig,
data,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
else:
self.pub.verify(
sig,
data,
padding.PKCS1v15(),
hashes.SHA256()
)
except InvalidSignature as e:
raise ValueError(f"validator: signature mismatch: {e}")
except Exception as e:
raise ValueError(f"validator: verify error: {e}")
+35
View File
@@ -0,0 +1,35 @@
from license_manager.models import LicensePayload
from license_manager.config import Config
from license_manager.hardware import DefaultHardwareProvider
import json
def test_models():
data = {
"license_key": "123",
"authorization_code": "abc",
"hardware_fingerprint": "xyz",
"status": "active",
"start_date": "2023-01-01T00:00:00Z",
"end_date": "2024-01-01T00:00:00Z",
}
payload = LicensePayload.from_dict(data)
assert payload.license_key == "123"
assert payload.expires_at == payload.end_date
print("Models verified.")
def test_config():
valid_cfg = Config(product="A", version="1", server="http://a", authorization_code="x")
valid_cfg.validate()
print("Config verified.")
def test_hardware():
hw = DefaultHardwareProvider(["mac", "hostname", "cpu", "memory"])
fp, details = hw.fingerprint()
assert fp is not None
assert type(details) is dict
print(f"Hardware verified: fp={fp}, details={json.dumps(details)}")
if __name__ == "__main__":
test_models()
test_config()
test_hardware()
+79
View File
@@ -0,0 +1,79 @@
from license_manager.storage import FileStore
from license_manager.validator import Validator
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
import base64
import json
import os
from datetime import datetime, timezone, timedelta
def test_storage():
test_file = "test_store.lic"
store = FileStore(test_file, b"super_secret_key_123")
data = b"hello crypto"
store.save(data)
loaded = store.load()
assert loaded == data
store.delete()
print("Storage AES-GCM verified.")
def test_validator():
# Make a dummy keypair
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
pub_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
val = Validator(pub_pem)
# create payload
exp = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()
payload_dict = {
"license_key": "abc",
"authorization_code": "def",
"hardware_fingerprint": "finger123",
"status": "active",
"start_date": datetime.now(timezone.utc).isoformat(),
"end_date": exp
}
payload_bytes = json.dumps(payload_dict).encode('utf-8')
# sign it using PKCS1v15
sig = private_key.sign(
payload_bytes,
padding.PKCS1v15(),
hashes.SHA256()
)
envelope = {
"algorithm": "RSA-SHA256",
"data": payload_dict, # in python json.dumps we need primitive
}
# To mimic envelope, Data is a string representing nested json
envelope = {
"algorithm": "RSA-SHA256",
"data": json.dumps(payload_dict),
"signature": base64.b64encode(sig).decode('utf-8')
}
env_bytes = json.dumps(envelope).encode('utf-8')
# Verify ok
p = val.verify(env_bytes, "finger123")
assert p.license_key == "abc"
# Verify fingerprint fail
try:
val.verify(env_bytes, "wrong_finger")
assert False
except ValueError as e:
assert "mismatch" in str(e)
print("Validator RSA verified.")
if __name__ == "__main__":
test_storage()
test_validator()
+47
View File
@@ -0,0 +1,47 @@
import threading
import time
from license_manager.models import HeartbeatRequest, HeartbeatResponse
from license_manager.heartbeat import HeartbeatManager
# Mock service for testing
class MockHeartbeatService:
def __init__(self):
self.call_count = 0
def send(self, req: HeartbeatRequest) -> HeartbeatResponse:
self.call_count += 1
# alternate failing and success
if self.call_count % 2 == 0:
raise ValueError("mock network error")
return HeartbeatResponse(status="ok", heartbeat_interval=1)
def test_heartbeat_manager():
service = MockHeartbeatService()
req = HeartbeatRequest(license_key="test", hardware_fingerprint="fp")
cb_calls = []
err_calls = []
def on_update(resp):
cb_calls.append(resp)
def on_error(err):
err_calls.append(err)
mgr = HeartbeatManager(service, req, interval_seconds=1, on_license_updated=on_update, on_error=on_error)
mgr.start()
# let it run for 2.5 seconds. Should make ~2 calls.
time.sleep(2.5)
mgr.pause()
time.sleep(1.5) # Shouldn't make calls while paused
mgr.stop()
assert service.call_count > 0, "Should have made calls"
assert len(err_calls) > 0, "Should have received errors based on mock"
print("Heartbeat manager verified.")
if __name__ == "__main__":
test_heartbeat_manager()
+21
View File
@@ -0,0 +1,21 @@
from license_manager.client import Client, ClientOptions, Callbacks
from license_manager.config import Config
from license_manager.hardware import DefaultHardwareProvider
from license_manager.storage import FileStore
import os
def test_client_init_offline():
cfg = Config(product="A", version="1", offline=True)
try:
cfg.public_key_pem = b"dummy"
cfg.license_file_path = "dummy.lic"
# Since it's offline and dummy.lic doesn't exist, it should raise
client = Client(cfg)
assert False, "Should raise offline load error"
except ValueError as e:
assert "offline mode requires preloaded license" in str(e) or "read public key" in str(e)
print(f"Caught expected error: {e}")
if __name__ == "__main__":
test_client_init_offline()
print("Client integration test verified basic offline fail fast.")