modify: 授权

This commit is contained in:
2026-09-21 14:03:47 +08:00
parent 66e82c02e7
commit 8a3dc8036c
19 changed files with 2826 additions and 30 deletions
+13 -1
View File
@@ -41,4 +41,16 @@ config
# lib
lib
auto.crt
auto.key
auto.key
# 机器码授权
# 签发私钥是授权的唯一信任根,绝不入库(丢了无法再签发,泄漏则人人可签发);
# license.lic 是设备运行期文件,公钥由构建期从环境变量/文件注入。
license_private*.pem
license_public.key
license.lic
# 台账库(默认在 ~/.chongming/,若显式放到项目内也不应提交)
licenses.db
licenses.db-wal
licenses.db-shm
授权台账.csv
+52
View File
@@ -29,6 +29,7 @@
| **构建打包** | PyInstaller / Nuitka 一键打包为独立可执行文件 |
| **Vue 3 前端** | Element Plus UI、Pinia 状态管理、类型安全的 API 客户端 |
| **Worker 运维管理** | 前端经 Zenoh 向单个副本下发 restart/stop/改配置指令(超管),前端可视化列表 + 分页管理 |
| **机器码授权** | 离线 Ed25519 签名 license:绑定机器指纹 + 插件白名单 + 有效期,未授权插件不加载 |
---
@@ -356,6 +357,56 @@ external 副本走同一 `__admin__` Queryable,路由发现、指令下发与
---
## 机器码授权
按客户设备「机器码」签发授权:**一份 license 绑定一台机器 + 一批插件 + 有效期**,
未授权的插件不加载、不注册路由、管理接口禁止操作。**纯离线** —— 设备用内置厂商公钥验签,
运行期零联网(与 Zenoh localhost、Caddy `tls internal` 的 air-gapped 形态一致)。
> 📖 **完整指南见 [docs/license.md](docs/license.md)** —— 密钥管理、激活流程、
> 命令行参考、可视化工具、机器指纹与容错判定、强制执行点、安全边界、
> 故障排查(含原因码全表)。下面只保留最小上手路径。
>
> 厂商侧有**两种等价工具**(共用同一份实现):不习惯命令行的话用可视化工具
> —— `cd utils/license-gui && uv sync && uv run chongming-license-gui`(签发 / 密钥 / 校验三页)。
### 最小上手路径
```bash
# ① 厂商侧:生成密钥对(一次性)。私钥离线保管并备份,绝不进仓库/产物
uv run chongming-license keygen -o license_private.pem
# → 输出公钥,按提示注入构建(CHONGMING_LICENSE_PUBKEY 环境变量 / license_public.key 文件)
# → 打包时缺少公钥会直接失败:缺公钥的产物会拒绝加载所有插件,等于砖头
# ② 设备侧:浏览器打开 http://<设备IP>:8000/static/license,登录后「复制申请码」
# ③ 厂商侧:签发
uv run chongming-license issue --request-code "CM1-客户发来的..." \
--plugins aibox_sophon_adapter,ip_sound_column \
--customer "某某科技" --days 365 -k license_private.pem -o license.lic
# ④ 设备侧:同一页面「导入并校验」→「重启网关」生效
```
签发会**自动记入台账**(`~/.chongming/licenses.db`,含机器指纹)。续期时直接复用,
**无需再向客户索取申请码**:
```bash
uv run chongming-license list # 台账检索(按到期日升序)
uv run chongming-license renew --lic-id L2026... --days 365 -k license_private.pem
uv run chongming-license export --out ./archive --csv ./台账.csv --by-customer
```
### 三条需要记住的约束
- **申请码 vs 机器码**:机器码是短哈希,只报它只能签出**精确匹配**的授权(换网卡即失效)。
申请码带回各来源原始值,才能签出支持换硬件的**容错**授权。
- **开发态默认不执行拒绝**:`[license] enabled` 默认 `false`,改配置也绕不过(打包产物恒为强制校验,
依据 `sys.frozen`/`__compiled__` 判定)。开发时想验证门禁需手动改成 `true`。
- **设备侧没有签发命令**:`chongming-cli` 是开发依赖,不随产物发布 —— 签发能力只存在于厂商机器上。
---
## 端点类型与路由注册
### 端点类型
@@ -859,5 +910,6 @@ uv pip install -e utils/<name> # 安装/更新工具包子包
- [配置说明](config/README.md)
- [插件开发指南](src/plugins/README.md)
- [进程插件开发指南(external 模式,从开发到部署)](docs/process-plugin.md)
- [机器码授权指南(密钥管理 / 激活流程 / 故障排查)](docs/license.md)
- [前端开发指南](src/chongming-web/README.md)
- [Worker 框架](utils/worker/README.md)
+34 -11
View File
@@ -11,7 +11,7 @@ upload_path = "uploads"
json5_path = "./config/zenoh_config.json5"
[cache]
backend = "sqlite" # "sqlite"(默认,本机共享) | "redis"(跨主机)
backend = "sqlite" # "sqlite"(默认,本机共享) | "redis"(跨主机)
# backend = "redis"
# path = "./cache_data/cache.db"
@@ -38,6 +38,22 @@ redis_prefix = "chongming:"
algorithm = "HS256"
secret_key = "your-secret-key-change-in-production"
[license]
# 机器码授权(插件白名单)。未授权插件不加载、不注册路由、管理接口禁止操作。
#
# enabled 只在**非打包态**(uv run serve / 测试)决定是否执行拒绝:打包产物
# (Nuitka/PyInstaller)恒为强制校验 —— 部署目录的 config.toml 是明文可写的,
# 若以配置开关为准,改一行就能绕过授权。
enabled = false
# 授权文件路径(相对网关工作目录)
path = "license.lic"
# 厂商公钥(base64 裸 32 字节,或 PEM)。公钥可明文放置:换公钥只会验签失败,
# 无法据以伪造授权(伪造需要厂商私钥)。构建时由 build.py 从
# CHONGMING_LICENSE_PUBKEY / license_public.key 注入。
public_key = ""
# 指纹命中阈值(N-of-M)。4 个可信来源下取 3 表示容忍单块网卡或单颗 eMMC 更换。
min_match = 3
[development]
debug = true
env = "development"
@@ -102,7 +118,7 @@ config_path = "./config/workers/config.toml"
[pyinstaller_build_import]
base = [
"utils", # public 骨架
"utils", # public 骨架
"utils.launch",
"utils.config",
"zenoh",
@@ -112,13 +128,16 @@ base = [
"chongming_worker.routes_publisher",
"chongming_request",
"chongming_auth",
"chongming_license", # 机器码授权(指纹采集/验签/判定)
"cryptography", # Ed25519 验签
"cryptography.hazmat.primitives.asymmetric.ed25519",
"server",
"module_bank", # 模块化打包工具
"module_bank", # 模块化打包工具
"sqlite_vfs",
"sqlite_vfs.core",
"sqlite_vfs.contrib",
"sqlite_vfs.contrib.fastapi_static",
"fastapi", # web 应用依赖
"fastapi", # web 应用依赖
"fastapi.staticfiles",
"fastapi.middleware.cors",
"fastapi.middleware.gzip",
@@ -149,16 +168,20 @@ base = [
"aiofiles",
"openai",
]
linux = [
"gunicorn.glogging",
"gunicorn.app",
"gunicorn.app.wsgiapp",
]
linux = ["gunicorn.glogging", "gunicorn.app", "gunicorn.app.wsgiapp"]
windows = []
[production.module_system]
path = [
["resources/app.mbank", "src/chongming/app", "app"],
["resources/plugins.mbank", "src/plugins", "plugins"],
[
"resources/app.mbank",
"src/chongming/app",
"app",
],
[
"resources/plugins.mbank",
"src/plugins",
"plugins",
],
]
type = "module-bank"
+858
View File
@@ -0,0 +1,858 @@
# 机器码授权指南
> 机器码授权按客户设备签发 license:**一份 license 绑定一台机器 + 一批插件 + 有效期**。
> 未授权的插件**不加载、不注册路由、管理接口禁止操作**。
> **纯离线** —— 设备用内置厂商公钥验签,运行期零联网,与现有 air-gapped 部署形态
> (Zenoh 仅 localhost、Caddy `tls internal`、无 phone-home)完全一致。
>
> 本文档覆盖 **密钥生成 → 设备导出申请码 → 厂商签发 → 设备导入 → 验证 → 故障排查** 全流程。
---
## 1. 原理与角色
### 1.1 为什么这么设计
| 决策 | 原因 |
| ---- | ---- |
| **非对称签名(Ed25519)** | 设备只需内置公钥即可验签;公钥可明文放置(换公钥只会验签失败,无法据以伪造)。对称方案不可行 —— 见 §9.1 |
| **纯离线** | 现场设备常无外网,且现有部署形态本就 air-gapped;联网激活需要新建厂商侧服务,与本项目架构冲突 |
| **N-of-M 指纹容错** | 把所有硬件标识哈希成一个值的话,换一块网卡或换一颗 eMMC 就失效。改为记录逐来源指纹、按命中数判定 |
| **打包态决定是否强制** | 部署目录的 `config.toml` 是明文可写的,若以配置开关为准,客户改一行就绕过了 |
| **未授权 = 不加载** | 代码不下发到现场,比"加载后返回 403"更彻底 |
### 1.2 三方角色
| 角色 | 持有 | 做什么 | 用什么工具 |
| ---- | ---- | ------ | ---------- |
| **厂商** | 签发**私钥** | 生成密钥对、签发 license | **可视化工具**(推荐)/ `uv run chongming-license`(CLI) |
| **设备** | 内置**公钥** | 导出申请码、导入 license、验签放行 | 浏览器 `/static/license`(网页) |
| **客户** | —— | 把申请码转给厂商、把 license 文件放回设备 | —— |
> **设备上没有签发命令**:`chongming-cli` 是开发依赖,不随打包产物发布。这是刻意的 ——
> 签发能力只存在于厂商机器上,设备端不携带。设备侧一律走网页。
>
> 厂商侧有**两种等价的工具**,共用同一份授权实现(`chongming-license` 库):
> 单台/少量、需要人工核对信息时用可视化工具;批量签发(几十上百台)用 CLI 脚本化。
> 详见 §4 与 §12。
### 1.3 全流程
```mermaid
sequenceDiagram
participant V as 厂商<br/>(私钥)
participant D as 设备<br/>(公钥)
participant C as 客户
Note over V: ① keygen 生成密钥对(一次性)<br/>公钥注入构建产物
D->>D: ② 采集硬件指纹
D->>C: ③ 申请码 CM1-xxxx-yyyy
C->>V: 转交申请码
V->>V: ④ 私钥签发 license.lic
V->>C: 交付 license.lic
C->>D: ⑤ 网页上传 / scp 到工作目录
D->>D: ⑥ 重启网关,按白名单加载插件
```
---
## 2. 密钥管理(厂商侧)
### 2.1 生成密钥对(一次性)
```bash
cd <项目根>
uv run chongming-license keygen -o license_private.pem
```
输出:
```
✓ 私钥已写入 license_private.pem(权限 0600)
厂商公钥(填入部署产物的 [license] 段):
public_key = "yGHeVrFjajV4XgtBkiE9X9KZ4uy0IzF56GkQ0wwqHG0="
公钥指纹:7a9a7c9d427ee864
```
- 私钥文件权限自动置 `0600`;已存在时需显式 `--force` 覆盖(覆盖后旧 license 仍可用旧公钥校验,
但无法再用旧私钥签新授权)。
- 记下 **公钥指纹**(前 16 位十六进制):设备端、日志、`/license/info` 都会显示它,
用于确认"设备内置的是哪把公钥"。
### 2.2 私钥保管要求
> ⚠️ 私钥是授权的**唯一信任根**。
>
> - **丢失** → 再也无法签发新授权(已签发的仍有效,因为校验只需公钥)。
> - **泄漏** → 任何人都能签发授权,整套机器码绑定形同虚设。
>
> 请离线保管、做好备份,**绝不提交仓库或打进部署产物**。
已内置的防护:
- `.gitignore` 忽略 `license_private*.pem` / `license_public.key` / `license.lic`
- `uv run build` 的 **授权私钥守卫**阶段按**内容**扫描 `build/`(跨 1MB 块边界也能检出),
发现私钥立即终止构建
- `keygen` 每次都会打印保管警示
### 2.3 公钥注入构建
构建时按以下顺序解析公钥,注入产物 `config.toml` 的 `[license] public_key`:
1. 环境变量 `CHONGMING_LICENSE_PUBKEY`
2. 环境变量 `CHONGMING_LICENSE_PUBKEY_FILE` 指定的文件
3. 项目根的 `license_public.key`
```bash
# 方式 A:环境变量(CI 推荐)
export CHONGMING_LICENSE_PUBKEY="yGHeVrFjajV4XgtBkiE9X9KZ4uy0IzF56GkQ0wwqHG0="
# 方式 B:文件
echo "yGHeVrFjajV4XgtBkiE9X9KZ4uy0IzF56GkQ0wwqHG0=" > license_public.key
# 方式 C:直接编辑 public/config.toml 的 [license] 段
```
> **打包时缺少公钥会直接失败** —— 这是刻意设计。缺公钥的产物会拒绝加载**全部**插件
> (谁都无法证明被授权),等于把设备变成砖头,而这种故障往往到客户现场才暴露,
> 代价远大于构建失败。仅内部演示可用 `CHONGMING_LICENSE_ALLOW_NO_KEY=1` 跳过(会打印警告)。
### 2.4 密钥轮换
公钥指纹进入判定,因此轮换是显式操作:
1. `keygen -o new_private.pem` 生成新密钥对
2. 用新公钥重新构建并分发产物
3. 已发出去的 license 需用**新私钥**重签(旧公钥不再被内置)
---
## 3. 激活流程
### 3.1 设备侧:导出申请码
浏览器打开 `http://<设备IP>:8000/static/license`,用 `admin` 登录(默认账号已在设备上 seed),
点「复制申请码」。
页面上同时可见:
- **机器码**(短哈希,如 `BC60-D20E-F863-7236-68E2-6A24`)—— 适合口头/日志核对
- **申请码**(单行文本,`CM1-<base64>-<校验和>`)—— 发给厂商签发
- **硬件指纹来源明细** —— 哪个来源采集成功、哪个缺失,装机时用来核对
> **为什么用申请码而不是只报机器码**:机器码是单向哈希,**反推不出各来源指纹**,
> 而 N-of-M 容错匹配需要逐来源原始值。只报机器码也能签(`issue --machine-code ...`),
> 但那样的授权是**精确匹配** —— 换一块网卡就失效。
命令行等价物(供调试;设备上通常没有 CLI):
```bash
uv run chongming-license show --request-code # 只输出申请码
uv run chongming-license show --sources # 各来源明细表
uv run chongming-license show --json # 全量 JSON(可存文件传给厂商)
```
### 3.2 厂商侧:签发
```bash
uv run chongming-license issue \
--request-code "CM1-eyJzb3VyY2VzIjp7..." \
--plugins aibox_sophon_adapter,ip_sound_column \
--customer "某某科技" --days 365 \
-k license_private.pem -o license.lic
```
输出:
```
✓ 已签发 license.lic
授权编号 L20260921-334B33
客户 某某科技
机器码 BC60-D20E-F863-7236-68E2-6A24
指纹来源 mac(不参与判定:machine_id);容错阈值 3
授权插件 ['aibox_sophon_adapter', 'ip_sound_column']
有效期 立即生效 → 2027-09-21
公钥指纹 3d956a50db56b53c
```
要签发给客户的三种入参(优先级从高到低):
| 入参 | 来源 | 容错 |
| ---- | ---- | ---- |
| `--request-code CM1-...` | 设备网页「复制申请码」 | ✅ 支持换硬件容错 |
| `--sources-json <文件>` | `show --json` 的导出文件(批量/邮件附件场景) | ✅ |
| `--machine-code XXXX-...` | 客户只读了短码 | ❌ 精确匹配,换网卡即失效 |
### 3.3 设备侧:导入 license
在同一个「授权」页点「选择 license 文件」→「导入并校验」。
- 文件落盘位置由 `[license] path` 决定,默认网关工作目录下的 `license.lic`
(systemd 部署即 `/data/chongming/license.lic`)。
- **校验不过不会覆盖现有文件** —— 现场误传坏文件不至于把已经能用的授权弄丢。
- 若改用 scp 手工放置,记得在页面上点「从磁盘重读」刷新状态。
### 3.4 重启生效
新授权的插件**需重启网关**才会加载(插件发现只在启动期执行)。导入成功后页面会提示
需要重启、以及哪几个插件待加载;接口响应里的 `restart_required` / `pending_plugins` 同理。
重启后日志:
```
[license] ✓ 授权有效(本机机器码 BC60-D20E-F863-7236-68E2-6A24)
[license] 编号 L20260921-334B33,客户 某某科技,到期 2027-09-21,授权插件 ['aibox_sophon_adapter', 'ip_sound_column']
[license] 厂商公钥指纹 3d956a50db56b53c
```
未授权插件会明确记日志:
```
[license] 插件 aibox_sophon_adapter 未获授权:该插件不在授权白名单内
授权门禁已拒绝 1 个插件(不加载、不注册路由、管理接口禁止操作): ['aibox_sophon_adapter']
```
---
## 4. 命令行参考
命令注册在 `chongming-cli`(根仓 dev 依赖),入口 `uv run chongming-license`。
**退出码**:`0` 成功/校验通过,`1` 失败(便于脚本判断)。
> 不习惯命令行的同学可用可视化工具,功能对等 —— 见 §12。
### 4.1 `keygen` — 生成密钥对(厂商侧,一次性)
| 参数 | 默认 | 说明 |
| ---- | ---- | ---- |
| `-o, --output` | `license_private.pem` | 私钥输出路径 |
| `--kid` | —— | 建议的密钥标识,仅提示用途 |
| `--force` | —— | 覆盖已存在的私钥文件 |
### 4.2 `show` — 导出机器码/申请码(设备侧)
| 参数 | 说明 |
| ---- | ---- |
| `--request-code` | 只输出申请码(单行,供客户粘贴回传) |
| `--sources` | 打印各硬件来源明细表(现场核对用) |
| `--json` | 以 JSON 输出全部内容 |
### 4.3 `issue` — 签发(厂商侧)
| 参数 | 必填 | 说明 |
| ---- | ---- | ---- |
| `--request-code` / `--sources-json` / `--machine-code` | 三选一 | 签发对象(见 §3.2 表) |
| `--plugins` | ✅ | 授权插件 type,逗号分隔 |
| `-k, --key` | | 私钥路径,默认 `license_private.pem` |
| `-o, --output` | | 输出路径,默认 `license.lic` |
| `--customer` | | 客户名称(仅展示/审计) |
| `--lic-id` | | 授权编号,默认 `L<日期>-<随机>` |
| `--kid` | | 密钥标识(写入文件,仅提示) |
| `--not-before` | | 生效日期 ISO8601,默认立即生效 |
| `--expires` | | 到期日期 ISO8601,**含当天** |
| `--days` | | 有效期天数(与 `--expires` 二选一) |
| `--note` | | 备注(合同号等) |
| `--min-match` | | 容错阈值(默认 3,仅影响提示文字) |
> 日期一律按 **UTC** 计算,与校验端保持一致 —— 否则时区跨日时会差一天
> (出现"刚签发即过期"或"到期后仍可用一天")。
### 4.4 `inspect` — 查看内容(可选验签)
```bash
uv run chongming-license inspect license.lic # 只看内容,不验签
uv run chongming-license inspect license.lic --key <公钥> # 顺带验签
uv run chongming-license inspect license.lic --public-key-file pub.key
```
公钥解析顺序:`--key` > `--public-key-file` > 根 `config.toml` 的 `[license] public_key`。
三者都没有时按"只看内容、不验签"处理(打印原始 JSON + 警告),不会报错退出。
### 4.5 `verify` — 按指定环境完整校验
```bash
# 在本机校验(含指纹比对与时钟防线)
uv run chongming-license verify license.lic --key <公钥>
# 顺带判定某插件是否在授权白名单内
uv run chongming-license verify license.lic --key <公钥> --plugin doubao
# 模拟另一台机器(厂商侧发货前自检:确认这份 license 只能在目标机器上用)
uv run chongming-license verify license.lic --key <公钥> \
--request-code "CM1-目标机器的申请码..."
```
`--machine-code` / `--sources-json` / `--request-code` 都是"用指定的机器指纹代替现场采集",
用于模拟目标设备。
### 4.6 台账命令(`list` / `renew` / `export`)
**每次 `issue` 都会自动把授权记入台账**(连同机器指纹),这是后续续期与检索的基础。
用 `--no-registry` 可跳过。台账库默认在 `~/.chongming/licenses.db`,
可用 `--db` 或环境变量 `CHONGMING_LICENSE_DB` 指定。
```bash
# 检索(默认按到期日升序,最急的排最前)
uv run chongming-license list # 全部 + 概览计数
uv run chongming-license list --status expiring # 只看即将到期
uv run chongming-license list --customer 某某科技 # 按客户
uv run chongming-license list --machine-code BC60 # 按机器码
uv run chongming-license list --plugin aibox_sophon_adapter
uv run chongming-license list --json # 供脚本处理
# 续期:**复用台账里的机器指纹,无需再向客户索取申请码**
uv run chongming-license renew --lic-id L20260921-584426 --days 365 -k license_private.pem
# 换机器续期(新机器要给出新指纹)
uv run chongming-license renew --lic-id L20260921-584426 --days 365 \
--request-code "CM1-新机器的申请码..." -k license_private.pem
# 导出归档:.lic 文件(可按客户分目录)+ CSV 清单
uv run chongming-license export --out ./archive --csv ./台账.csv --by-customer
uv run chongming-license export --out ./expired --status expired
```
`list` 输出示例:
```
台账库: /home/user/.chongming/licenses.db
总计 3 条 —— 有效 1、即将到期 1、已过期 1、永久 0、未生效 0
符合条件 3 条:
授权编号 客户 机器码 状态 到期 插件
------------------------------------------------------------------------------------------------------------
L20260921-8FAA47 异机客户 AAAA-BBBB-CCCC-DDDD-EEEE-FFFF 已过期(-263d) 2026-01-01 doubao
L20260921-584426 某某科技 BC60-D20E-F863-7236-68E2-6A24 即将到期(20d) 2026-10-11 aibox_sophon_adapter,ip_sound_column
L20260921-76E11D 另一客户 BC60-D20E-F863-7236-68E2-6A24 有效(400d) 2027-10-26 ip_sound_column
```
---
## 5. 配置参考
```toml
[license]
path = "license.lic" # 授权文件路径(相对网关工作目录)
public_key = "" # 厂商公钥(base64 裸 32 字节,或 PEM),构建时注入
min_match = 3 # 指纹命中阈值 N-of-M
# enabled = false # 仅非打包态生效;打包产物恒为强制校验
```
| 键 | 默认 | 说明 |
| -- | ---- | ---- |
| `path` | `license.lic` | 相对网关工作目录(systemd 部署即 `/data/chongming/license.lic`) |
| `public_key` | `""` | base64 裸 32 字节或 PEM。为空时所有插件都会被拒(`no_public_key`) |
| `min_match` | `3` | 见 §6.3 |
| `enabled` | `false` | **仅非打包态**读取。打包产物恒为强制校验(见 §7.2) |
相关环境变量:
| 变量 | 作用 |
| ---- | ---- |
| `CHONGMING_LICENSE_MODE` | 强制开启/关闭校验(`on`/`off` 等)。**仅非打包态生效** |
| `CHONGMING_LICENSE_PUBKEY` | 构建期注入的公钥 |
| `CHONGMING_LICENSE_PUBKEY_FILE` | 构建期从文件读取公钥 |
| `CHONGMING_LICENSE_ALLOW_NO_KEY` | 设为 `1` 允许无公钥构建(仅内部演示) |
---
## 6. 机器指纹与容错判定
### 6.1 采集来源
按可信度排序(`utils/license/src/chongming_license/machine.py` 的 `SOURCES`):
| 来源 | 位置 | 说明 |
| ---- | ---- | ---- |
| `board_serial` | `/proc/device-tree/serial-number`、DMI `board_serial` | 逐机唯一,最稳定 |
| `emmc_cid` | `/sys/block/mmcblk*/device/cid` | 芯片出厂唯一,不随系统重装变化 |
| `cpu_serial` | `/proc/cpuinfo` 的 `Serial` | ARM 常见;部分型号全 0(会被剔除) |
| `mac` | `/sys/class/net/*/address` | 取**字典序最小**的物理网卡,见下 |
| `product_uuid` | `/sys/class/dmi/id/product_uuid` | x86 有效,ARM 通常缺失 |
| `machine_id` | `/etc/machine-id` | ⚠ **weak**,见 §6.2 |
取值规则:
- 归一化为小写、仅保留 `[0-9a-z]`(`AA:BB:CC` 与 `aa-bb-cc` 归一一致)
- 剔除占位/无效值:空、`unknown`/`none`/`to be filled by oem` 等、全 `0`、全 `f`、长度 < 4
- `mac` 取**字典序最小**而非"第一块网卡":这样插入/移除其它网卡(USB 网卡、调试接口)
以及接口重命名(`eth0` ↔ `enp1s0`)都不影响指纹,只有最小值那块网卡本身被换才会变
- 回环及其别名(`lo`、`loopback0`)无条件排除 —— `loopback0` 带 `device` 符号链接,
会骗过"物理网卡"判定(WSL 上实测到)
IP 地址**不参与**指纹(会变)。
### 6.2 weak 来源:为什么不参与判定
`/etc/machine-id` 在**同镜像批量刷机**场景下,若未在首次启动重新生成,
**所有机器的该值完全相同**。若让它计票,等于给"把整盘镜像拷到另一台机器"
白送一票 —— 阈值越高越危险。
因此它被标记为 `weak`:
- **仍会记录**进 license(便于诊断、也便于售后核对"这台机器确实是我签的那台")
- **不计入判定阈值**
- 前端/CLI 展示时带 `*` 标注
仅当双方**都只采集到 weak 来源**时才降级纳入,并在结果里标记 `degraded: true`
(此时安全性等同"只靠 machine_id 绑定",调用方应告警)。
### 6.3 N-of-M 判定
只比对**双方都有**的来源(license 签发时某来源缺失、或本机现在读不到该来源,
都不计入分母),命中数达到阈值即视为同一台机器:
```
阈值 = max(1, min(min_match, 可比较来源数))
```
在真实硬件(4 个可信来源 + 阈值 3)下的实测行为:
| 场景 | 命中 | 结果 |
| ---- | ---- | ---- |
| 同一台机器 | 4/4 | ✅ 通过 |
| 换一块网卡 | 3/4 | ✅ 通过 |
| 换一颗 eMMC | 3/4 | ✅ 通过 |
| 同时换网卡 + eMMC | 2/4 | ❌ 拒绝 |
| 整盘拷贝到另一台机器 | 0/4 | ❌ 拒绝(weak 来源不计票) |
### 6.4 上机验证(**部署前必做**)
这是唯一无法在开发机上确认的环节。在**真实硬件**(如 Sophon BM1684)上:
```bash
uv run chongming-license show --sources
```
要确认两件事:
1. **两台机器的机器码必须不同** —— 若相同,说明所有可信来源都重复了
(典型原因:同镜像刷机且 `machine-id` 是唯一来源),需要重新评估来源清单
2. **同一台机器重启后机器码必须一致** —— 若变化,说明某个来源不稳定,要从清单里换掉
同时检查来源明细:可信来源越多,容错越好。若只有 1 个可信来源或大量"缺失",
说明该硬件的序列号不可读,此时换网卡就会失授权 —— 应在上线前解决。
调整来源只需改 `utils/license/src/chongming_license/machine.py` 的 `SOURCES` 元组与对应 reader。
---
## 7. 强制执行点
### 7.1 门禁位置
| 位置 | 作用 | 为什么必须在这里 |
| ---- | ---- | ---------------- |
| `lifespan_setup._discover_plugins()` | **唯一收口**:未授权插件从插件列表剔除,`load_workers` / `validate_contracts` / `register_routes` / MQTT 主题汇总随之全部一致 | 契约校验会把孤儿 TOML 路由当**硬性错误并终止启动**。若实现成"跳过 import 但保留在列表里",该插件的 TOML 路由就无人注册 → **设备起不来** |
| `RouteRegistry.set_denied_types()` | 忽略未授权类型的路由描述符(与 `load_mode` 无关) | 否则把插件 `config.toml` 的 `load_mode` 改成 `external`,网关就不加载它、`set_inline_types` 也不再抑制它,其独立进程推来的描述符会被正常挂载 = 绕过授权 |
| `worker_admin._ensure_licensed()` | 未授权插件的扩缩容/重启/读改配置一律 403(7 个端点全部覆盖) | 否则可借扩缩容把未授权插件重新拉起,或改其配置 |
| `/api/v1/license/*` | 管理接口,仅超管;导入**先校验后落盘** | 误传坏文件不得覆盖现有可用授权 |
| 打包态检测 | 决定**是否执行拒绝** | 见 §7.2 |
### 7.2 为什么改配置绕不过
部署目录的 `config.toml` 是**明文可写**的,任何 `enabled = false` / `mode = "off"`
都能被客户一键改掉。因此"是否执行拒绝"不以配置为准,而以**打包形态**为准:
```python
is_packaged() # sys.frozen(PyInstaller)或 __compiled__(Nuitka)
```
- **打包产物** → 恒为强制校验。此时若读到"关闭授权"的配置/环境变量,会留下告警日志
(陷阱日志)但**不予采纳**
- **非打包态**(`uv run serve` / 测试)→ 默认不执行拒绝,以免影响开发流程;
需显式 `[license] enabled = true` 或 `CHONGMING_LICENSE_MODE=on` 才生效
> ⚠️ 该检测假设按 `deploy/chongming.service` 用**编译产物**部署。
> 若现场改用 `uv run` 启动,则检测不生效 —— 这也意味着不能用"未打包"的方式投产。
---
## 8. 管理接口与前端
### 8.1 API(均需超级管理员 Bearer token)
| 端点 | 说明 |
| ---- | ---- |
| `GET /api/v1/license/info` | 机器码、申请码、授权状态、插件授权全景、各来源明细、告警 |
| `POST /api/v1/license/reload` | 从磁盘重读授权(手工替换文件后无需重启刷新状态) |
| `POST /api/v1/license/file` | 上传导入 license(multipart;先校验后落盘,上限 64KB) |
`/license/info` 关键字段:
```jsonc
{
"enforce": true, // 是否执行拒绝
"enforce_forced_by_packaging": true, // 是否由打包态强制
"license_path": "license.lic",
"min_match": 3,
"request_code": "CM1-...", // 直接复制发给厂商
"status": {
"ok": true, "code": "ok", "message": "授权有效",
"machine_code": "BC60-D20E-...",
"public_key_fingerprint": "3d956a50db56b53c",
"match": { "matched": 4, "total": 4, "degraded": false, "reason": "..." },
"payload": { "lic_id": "...", "customer": "...", "plugins": [...] },
"licensed_plugins": ["aibox_sophon_adapter", "ip_sound_column"]
},
"plugins": {
"discovered": [...], // 盘扫到的
"licensed": [...], // 已授权
"denied": { "doubao": { "code": "plugin_not_licensed", "message": "..." } },
"loaded": [...] // 当前已加载
},
"sources_detail": [ { "name": "mac", "present": true, "weak": false, "value": "..." } ],
"warnings": [...]
}
```
### 8.2 前端「授权」页
地址:`http://<设备IP>:8000/static/license`(前端 `base = /static/`,顶部导航有「授权」入口)
- **机器码**大字号展示,一键复制
- **申请码**一键复制(附输入框便于手动选中)
- **授权信息**:授权文件路径、是否有校验、授权编号/客户/签发时间/有效期、指纹比对结论、公钥指纹、备注
- **插件授权表**:每个插件的 授权 / 已加载 / 说明(未授权原因)
- **硬件指纹来源表**:来源 / 说明 / 采集状态 / 值 / 细节,weak 来源带 `*`
- **导入授权文件**:选择文件 + 导入并校验 + 从磁盘重读
登录复用「管理端 token 单例」(`cm_admin_token`),在 Worker 管理页登录过则无需重复登录。
---
## 9. 安全边界与已知缺口
### 9.1 诚实的边界说明
**离线 + root 的天花板是"阻止随手拷贝",不是"阻止逆向"。**
有 root 权限的人可以改二进制跳过校验,或用 `LD_PRELOAD` 伪造指纹文件读取。这是所有
纯离线硬件绑定方案的共同边界,不应对外承诺更强。
**时间防线同理**:`clock` 模块用共享缓存里的高水位标记检测系统时间回拨
(默认容差 1 天;水位超前当前时间 90 天以上判为 RTC 异常并重新起算,避免把设备永久卡死)。
删掉缓存文件即可失效 —— 它只是抬高"随手改系统时间续期"的门槛。
**真正难以绕过的是签名**:伪造 license 需要厂商私钥。
### 9.2 `.mbank` 加密不能当授权信任根
根仓 `resources/*.key` 那套 `.mbank` 验签用的是 **HMAC 对称算法**
(`sqlite_vfs/core.py` 的 `sign` → `hmac.new`),且密钥与银行同目录发布。
它只能算**防篡改/混淆**,**不能**作为厂商信任根 —— 授权必须用非对称。
`test/test_license_build.py::test_mbank_signing_is_not_a_trust_root` 固化了这一认知:
若将来 `.mbank` 改为非对称,该测试会提醒更新文档。
### 9.3 已知缺口(本轮范围外)
| 缺口 | 影响 | 彻底封堵需要 |
| ---- | ---- | ------------ |
| **external/进程插件** | 网关侧只拦住了 HTTP 暴露;直接拉起 `chongming-worker --type X` 进程仍会运行并可能操作 MQTT/定时任务 | 在 `chongming-worker` SDK 的 `process.py` / `plugin.py` 加启动自检(需发布新 wheel,外部插件容器升级后生效) |
| **Rust 进程插件** | 同上,且若未覆盖会成为绕过点 | 同步改 `utils/worker-rs` |
| **动态加载新授权插件** | 导入新 license 后需重启才生效(插件发现只在启动期执行) | 实现运行期插件加载,是独立功能 |
| **打包态检测** | 依赖"用编译产物部署"这一前提 | 无(部署方式约束) |
---
## 10. 故障排查
### 10.1 按现象定位
| 现象 | 原因码 | 处理 |
| ---- | ------ | ---- |
| 所有插件都不加载 | `license_missing` | license 文件不在 `[license] path` 指定位置;网页导入或放好文件后重启 |
| 所有插件都不加载 | `no_public_key` | 产物未注入公钥(构建漏了)。用 `CHONGMING_LICENSE_PUBKEY` 重新构建 |
| 所有插件都不加载 | `signature_invalid` | 文件被篡改,或不是本厂商公钥对应私钥签的。核对 `public_key_fingerprint` |
| 所有插件都不加载 | `machine_mismatch` | 授权不是为本机签发的,或换了硬件。看 `/license/info` 的 `status.match.reason` |
| 所有插件都不加载 | `expired` | 授权过期,需续签 |
| 所有插件都不加载 | `not_yet_valid` | 授权尚未生效(`not_before` 晚于当前日期) |
| 所有插件都不加载 | `clock_rollback` | 系统时间被回拨。检查 RTC/时区/NTP |
| 所有插件都不加载 | `fingerprint_unavailable` | 本机读不到任何硬件指纹。用 `show --sources` 排查 |
| 所有插件都不加载 | `license_malformed` | JSON 损坏或载荷版本高于程序(提示升级网关) |
| 某个插件不加载 | `plugin_not_licensed` | 该插件不在白名单。用 `--plugins` 加上后**重签** |
| 某个插件不加载 | 其他原因码 | 整份 license 无效时**所有**插件都被拒,逐个看日志 |
| 管理接口 403 | —— | 该插件未授权,或当前用户不是超管 |
| 配置了但完全没反应 | `disabled` | 非打包态默认不执行拒绝。把 `[license] enabled` 改成 `true`(或设 `CHONGMING_LICENSE_MODE=on`) |
| 打包时报"缺少厂商授权公钥" | —— | 见 §2.3,按三种方式之一提供公钥 |
| 打包时报"发现私钥" | —— | 产物里混入了私钥(`.pem` 或被改名的私钥内容)。移出 `build/` 与 `resources/` 后重建 |
### 10.2 原因码全表
| 代码 | 含义 |
| ---- | ---- |
| `ok` | 授权有效 |
| `disabled` | 校验未启用(开发/测试模式) |
| `license_missing` | 未找到授权文件 |
| `license_malformed` | 格式错误或内容损坏 |
| `signature_invalid` | 签名校验失败(被篡改或非本厂商签发) |
| `unsupported_alg` | 不支持的签名算法(仅支持 `ed25519`) |
| `no_public_key` | 未配置厂商公钥 |
| `machine_mismatch` | 机器码不匹配(非本机授权) |
| `not_yet_valid` | 授权尚未生效 |
| `expired` | 授权已过期 |
| `clock_rollback` | 检测到系统时间被回拨 |
| `plugin_not_licensed` | 该插件不在授权白名单内 |
| `fingerprint_unavailable` | 本机无法采集硬件指纹 |
### 10.3 常见困惑
**"为什么我配了 license 却完全没反应?"**
非打包态默认 `enabled = false`(不干扰日常开发)。打包产物恒为强制校验,改配置关不掉。
开发时想验证门禁,必须手动把 `[license] enabled` 改成 `true`。
**"我机器上只认出 1 个指纹来源,是不是坏了?"**
虚拟机(WSL/容器)没有设备树、eMMC、DMI,所以只剩 `mac`(虚拟 MAC)与 `machine_id`(weak,不计票)。
真实硬件上 `board_serial` / `emmc_cid` / `cpu_serial` 都会出现。见 §6.4 上机验证。
**"我只报机器码行不行?"**
行,但授权是精确匹配,换网卡即失效。建议一律用申请码。
**"导入新 license 后为什么还要重启?"**
插件发现只在启动期执行。页面会提示需要重启及待加载的插件。
**"能不能签一份授权给多台机器用?"**
当前不支持 —— 判定必然走"逐来源指纹容错匹配"或"机器码精确匹配"其中之一,两者都绑定单机。
批量场景的处理方式是:每台设备各导出申请码,逐台签发(`issue` 可脚本化,
`--lic-id` 可指定编号便于对账)。若确需"一授权多机",需要扩展载荷格式
(例如加入机器码数组),属新功能。
---
## 11. 相关文件与测试
### 11.1 文件索引
| 路径 | 说明 |
| ---- | ---- |
| `utils/license/`(`chongming-license`) | 库:`machine.py`(指纹/机器码/申请码)、`models.py`(载荷格式)、`issue.py`(载荷拼装/签名/写盘)、`verify.py`(规范化/验签/判定)、`clock.py`(时钟防线)、`registry.py`(**已签发台账**)、`__init__.py`(`LicenseManager` 门面) |
| `utils/cli/src/chongming_cli/license.py` | 厂商侧 CLI(`chongming-license`,属 dev 依赖) |
| `src/chongming/app/core/licensing.py` | 网关侧适配:配置读取、打包态判定、门禁单例、启动横幅 |
| `src/chongming/app/core/lifespan_setup.py` | `_scan_plugins()`(盘扫,不过滤)/ `_discover_plugins()`(**门禁收口**)/ `denied_plugin_types()` / `log_license_banner()` |
| `src/chongming/app/core/route_registry.py` | `set_denied_types()` + `_handle_sample` 中的拒绝分支 |
| `src/chongming/app/routes/licensing.py` | `/license/*` 管理端点 |
| `src/chongming/app/routes/worker_admin.py` | `_ensure_licensed()` 及 7 处调用 |
| `src/chongming-web/src/views/LicenseView.vue` | 前端「授权」页 |
| `src/chongming-web/src/api/license.ts` | 前端 API 封装 |
| `utils/cli/src/chongming_cli/build.py` | 公钥注入 `_set_license_pubkey()` + 私钥守卫 `_stage_license_guard()` |
| `utils/license-gui/`(`chongming-license-gui`) | **可视化工具**(厂商侧):签发 / 台账 / 密钥 / 查看校验四页,独立 venv 不污染网关环境 |
| `config.toml` / `public/config.toml` | `[license]` 配置段(`public/` 为产物模板) |
| `config.toml` → `[pyinstaller_build_import]` | PyInstaller 白名单含 `chongming_license` / `cryptography` |
### 11.2 license 文件格式
```jsonc
{
"payload": {
"v": 1,
"lic_id": "L20260921-334B33",
"customer": "某某科技",
"machine_code": "BC60-D20E-F863-7236-68E2-6A24",
"sources": { "board_serial": "...", "emmc_cid": "...", "mac": "..." },
"plugins": ["aibox_sophon_adapter", "ip_sound_column"],
"not_before": null, // 含当天;null = 立即生效
"expires_at": "2027-09-21", // 含当天;null = 永久
"issued_at": "2026-09-21T01:30:59Z",
"note": "合同 HT-2026-0912"
},
"alg": "ed25519",
"kid": "vendor-2026",
"sig": "<base64,覆盖 payload 的规范化 JSON>"
}
```
**签名覆盖 payload 原始字节的规范化形式**(`canonical_json`:键排序、无空白),
因此签发与校验**必须共用同一个函数**(`chongming_license.verify.canonical_json`),
且载荷内**不允许浮点数**(float 的文本表示存在多种合法写法,会破坏字节级一致性)。
校验顺序是**先验签、后解析模型** —— 反过来会让默认值填充/字段顺序变化改变字节导致验签失败。
### 11.3 测试
| 文件 | 覆盖 | 断言数 |
| ---- | ---- | ------ |
| `test/test_license_verify.py` | 库层:归一化、机器码、容错匹配(含 weak 不计票)、验签/篡改/换公钥/字段重排、有效期边界、机器码-only 回退、申请码、时钟防线 | 52 |
| `test/test_license_gate.py` | 网关门禁:剔除未授权插件、**契约校验不硬失败**、完全未授权、过期、管理接口 403、路由描述符拦截、HTTP 端点与导入安全性 | 28 |
| `test/test_license_build.py` | 公钥注入(段内替换、注释保留)、解析优先级、私钥守卫(含跨块边界、证书不误判)、缺公钥构建失败、`.mbank` 非信任根 | 22 |
| `test/test_license_registry.py` | 台账:记录派生、五种状态(含到期当天边界)、检索与排序、**续期复用指纹**、备注不被重导入覆盖、导出(.lic/CSV/中文 BOM)、结构版本、并发打开 | 53 |
```bash
# 三个测试都支持独立运行(无需 pytest)
python test/test_license_verify.py
python test/test_license_gate.py
python test/test_license_build.py
python test/test_license_registry.py
# 或
uv run pytest test/test_license_*.py
```
回归关注点:`test_route_registry.py`(确认 `set_denied_types` 默认为空集时不影响既有行为)、
`test_worker_isolation.py`(确认未破坏进程插件隔离)。
---
## 12. 可视化工具(PySide6,厂商侧)
供商务、售后等不习惯命令行的同学使用,功能与 §4 的 CLI **完全对等**。
所有授权逻辑调用同一个 `chongming-license` 库 —— 界面不重复实现密码学、载荷格式或规范化
(一旦有两处实现,就会出现"自己签的自己验不过")。
### 12.1 安装与运行
```bash
cd utils/license-gui
uv sync
uv run chongming-license-gui
```
> **独立环境,不污染网关**:本包有自己的 `pyproject.toml` 与 `.venv`,
> **不是**根仓 workspace 成员,因此根仓 `uv sync` 不会拉入 PySide6(约 167MB)。
> 主仓 venv 里不会出现 PySide6。
无图形环境(SSH/CI)下可做冒烟检查:
```bash
cd utils/license-gui
QT_QPA_PLATFORM=offscreen uv run python smoke_test.py # 25 项断言
```
### 12.2 三个页面
| 页面 | 内容 |
| ---- | ---- |
| **签发**(默认) | 签名私钥状态与公钥指纹 → 签发依据(申请码 / 来源 JSON / 仅机器码)→ 勾选授权插件 → 客户/编号/备注/有效期 → **载荷预览** → 签发并保存(默认自动记入台账) |
| **台账** | 检索已签发授权(客户/机器码/插件/状态/关键字/即将到期)→ **续期**、编辑备注、查看详情、删除 → 导出 `.lic`(可按客户分目录)与 CSV 清单。见 §13 |
| **密钥** | 生成密钥对(私钥 `0600`,公钥自动进剪贴板);加载现有私钥并显示对应公钥与指纹 |
| **查看 / 校验** | 打开 `.lic` 看全部字段;填公钥验签;**指纹比对基准**可选"本机"或"指定机器(填申请码)" |
设计上刻意做的几件事:
- **所见即所签**:预览用的是与签名**同一个载荷对象**(`build_payload`),
不是另拼一份 —— 按下签发前就能看清"给谁、哪台机器、哪些插件、多久"。
- **窗口标题带公钥指纹**:同时开多个实例或截图沟通时,能一眼确认"当前用的是哪把密钥"。
- **签发前强制核对指纹**:加载私钥后立即显示公钥指纹,与设备端显示比对;
拿错密钥的代价是重签并追回已发出的文件,必须在这一步拦住。
- **"指定机器"校验**:厂商机器自己的指纹当然对不上客户设备。不指定目标机器时
只能验出"签名有效",验不出"机器码是否真的匹配" —— 这是**发货前自检**的关键。
- **weak 来源如实标注**:解析申请码后明确列出"可信来源 N 个 / 不参与判定(weak):…",
避免操作者误以为来源越多越保险。
- **仅机器码模式带警示**:界面直说"换一块网卡就会失授权,建议改用申请码"。
### 12.3 与 CLI 的对应
| GUI 操作 | CLI |
| -------- | --- |
| 密钥 → 生成密钥对 | `chongming-license keygen` |
| 密钥 → 加载私钥 | `chongming-license inspect --key` / `keygen` 的输出 |
| 签发 → 签发并保存 | `chongming-license issue` |
| 查看 / 校验 → 仅查看内容 | `chongming-license inspect` |
| 查看 / 校验 → 指定机器 | `chongming-license verify --request-code` |
批量签发(一次几十台)建议直接用 CLI 脚本化(`--lic-id` 可指定编号便于对账);
GUI 面向单台/少量、需要人工核对信息的场景。
### 12.4 使用注意
- GUI 会在 `QSettings` 里记住**私钥路径、客户名、备注、输出目录、有效期天数**
(便于连续签发),**不会**记住私钥内容本身。
- 界面里的信息框/确认框是模态的(会阻塞等待点击)。因此 GUI 的自动化测试
(`smoke_test.py`)必须先替换这些弹窗,否则会挂住 —— 这是该测试唯一需要 monkeypatch 的地方。
---
## 13. 已签发台账
### 13.1 为什么需要台账
签出去的 license 会散落在邮件、聊天记录、下载目录里。等到要续期时,常见困境是:
- 不知道当初给哪台机器签过、签的哪些插件
- **拿不到那台机器的硬件指纹** —— 而续期需要它;现场还可能已经联系不上客户
- 到期了没人知道,客户突然反馈"插件不能用了"才发现
台账把每次签发**连同机器指纹一起**存下来,于是:按客户/机器码/插件/有效期检索、
到期前主动列出该续期的清单、**续期直接复用台账指纹**(最大价值)、原样导出当初的
文件(审计/补发)、识别"哪份是密钥轮换前的旧公钥签的"。
### 13.2 位置与安全
| 项 | 说明 |
| -- | ---- |
| 默认路径 | `~/.chongming/licenses.db` |
| 覆盖方式 | `--db <路径>` 或环境变量 `CHONGMING_LICENSE_DB`;GUI 在「台账」页选择并记住 |
| 存储 | 单文件 SQLite(纯标准库,无服务、可直接进备份),WAL 模式支持多窗口并发 |
| 版本 | 库内 `PRAGMA user_version`;不匹配时**明确报错**而不是让查询诡异失败 |
| 敏感度 | 含客户设备的硬件指纹与授权信息,属**厂商内部数据**,不宜外发 |
刻意放在用户主目录而非项目内:台账必须能跨项目、跨工具长期留存,放在仓库里容易被
清理脚本删掉或换机器时随项目一起丢掉。
### 13.3 自动入库
`issue` 与 GUI 的「签发并保存」默认把授权写入台账(GUI 上是一个默认勾选的复选框):
```
✓ 已签发 license.lic
授权编号 L20260921-584426
...
已入台账 /home/user/.chongming/licenses.db
续期:`chongming-license renew --lic-id L20260921-584426 --days 365`
(复用台账指纹,无需客户再报申请码)
```
入库失败**不会**把"已签发"变成失败 —— 文件已经生成好了,只是台账没记上,
此时会明确告警(避免误以为需要重签)。
### 13.4 状态与字段
状态按优先级判定:`not_yet_valid`(未到生效日)> `expired` > `expiring`(窗口内)
> `forever`(无到期日)/ `active`。判定**复用同一个 `status()` 方法**,
因此各状态互斥、计数之和等于总数(用独立的 SQL 条件会因重叠而算错)。
关键字段:授权编号、客户、机器码、授权插件、**各来源指纹**、生效/到期日、签发时间、
密钥标识、**签发时的公钥指纹**、原始 license 文件内容、备注。
**备注**是本台账唯一允许人工编辑的字段("已电话确认续期"、"设备已返修"…),
**重新导入同一份 license 不会覆盖它** —— 载荷里的 `note` 是签名内容的一部分,
台账的 `note` 是厂商自己的批注,二者同名但不同用途。
### 13.5 续期(本功能的核心)
```
台账 → 选中一条 → 「续期选中…」→ 只改要变的部分(有效期/插件/客户)→ 续期并保存
```
- **指纹复用自台账**,无需再向客户索取申请码 —— 这是台账最大的价值
- 默认**生成新授权编号**,新旧都留在台账里,可追溯;也可选"沿用原授权编号"
- 旧记录会被自动加上 `已续期 → <新编号>` 的批注,从任一编号都能串起来
- 原授权若当初是"仅机器码精确匹配"签的(没有来源指纹),续期会明确拒绝并提示
重新索取申请码 —— 而不是悄悄签出一份会在设备上因指纹不符被拒的授权
换机器续期时用 `--request-code` 给出新机器的指纹:
```bash
uv run chongming-license renew --lic-id L20260921-584426 --days 365 \
--request-code "CM1-新机器的申请码..." -k license_private.pem
```
### 13.6 导出与归档
```bash
uv run chongming-license export --out ./archive --csv ./台账.csv --by-customer
```
- `.lic` 文件名形如 `<授权编号>_<机器码>.lic`,**内容原样**(可直接补发给客户)
- `--by-customer` 按客户名分子目录,便于打包发给对应客户
(客户名里的路径非法字符会被安全替换)
- CSV 以 **UTF-8 BOM** 写入:否则 Excel 在中文 Windows 下按 GBK 解码会乱码;
插件列表和备注含逗号时按 CSV 规范加引号转义
### 13.7 使用注意
- 台账**只删记录,不会吊销已发出的 license** —— 客户手里的文件仍然有效,
因为离线方案里没有吊销通道(见 §9.3)。删除前请确认这一点。
- 台账记录了签发时的公钥指纹,密钥轮换后可以用它筛出"需要重签"的历史授权。
- 台账规模(几百到几千条)完全在 SQLite 的舒适区;筛选逻辑放在 Python 侧以复用
`status()`,避免 SQL 与 Python 两套状态判定漂移。
+17
View File
@@ -28,6 +28,23 @@ launch_worker_count = "cache"
algorithm = "HS256"
secret_key = "your-secret-key-change-in-production"
[license]
# 机器码授权(插件白名单)。未授权插件不加载、不注册路由、管理接口禁止操作。
#
# 打包产物(Nuitka/PyInstaller)**恒为强制校验**,不存在可被配置绕过的开关:
# 部署目录的 config.toml 是明文可写的,任何 enabled/mode 开关都能被一键改掉。
# 只有非打包态(开发/测试)才会读取 enabled,默认关闭以免影响开发流程。
# enabled = false
# 授权文件路径(相对网关工作目录)
path = "license.lic"
# 厂商公钥(base64 裸 32 字节,或 PEM)。公钥可明文放置:换公钥只会验签失败,
# 无法据以伪造授权(伪造需要厂商私钥)。
# 构建时由 build.py 从 CHONGMING_LICENSE_PUBKEY 或 license_public.key 注入;
# 缺失时构建会失败(避免产出无法验签、所有插件都加载不了的"砖头"包)。
public_key = ""
# 指纹命中阈值(N-of-M)。4 个可信来源下取 3 表示容忍单块网卡或单颗 eMMC 更换。
min_match = 3
[production]
debug = false
env = "production"
+10 -2
View File
@@ -2,6 +2,7 @@ from .config import ConfigLoader
import os
from module_bank import PythonToSQLite
from typing import Callable
from pathlib import Path
# module_bank 依赖在每次加载 PyArmor 加密模块时都会 print 一行
# "已部署 PyArmor 运行时",启动期会刷屏(约 50 行)。依赖包不便改动,
@@ -36,8 +37,15 @@ def init_module_bank(config):
]
# 遍历所有打包器,安装导入器(模块清单不再打印,避免启动刷屏)
for packer in packer_list:
key = packer.get_key()
packer.install_importer(key=key)
key_file_path = Path(packer.db_path).with_suffix(".key")
with open(key_file_path, "r", encoding="utf-8") as f:
key = f.read().strip()
packer.install_importer(
key=key,
signature_key="my_key",
signature_key_id="ci-2026",
allow_unsigned=False,
)
def launch(run_fun: Callable[[dict, dict], None], run_mode: str):
+1
View File
@@ -28,6 +28,7 @@ dependencies = [
"bytedance",
"chongming-cache",
"chongming-auth",
"chongming-license",
]
description = "基于 modules-bank 的 fastapi 打包脚手架"
name = "chongming-zenoh"
+11 -1
View File
@@ -23,7 +23,13 @@ from .core.logger import get_logger
# from .core.static_files import SVFSStaticFiles
from sqlite_vfs.contrib.fastapi_static import SVFSStaticFiles
from .core import lifespan_setup, call_stats
from .routes import storage_router, stats_router, worker_admin_router, auth_router
from .routes import (
storage_router,
stats_router,
worker_admin_router,
auth_router,
license_router,
)
config = get_config()
@@ -63,6 +69,9 @@ async def lifespan(app: FastAPI):
is_inline_owner = manager.acquire_inline_owner()
app.state.inline_owner_locked = is_inline_owner
# 4.3 机器码授权状态横幅(需 WorkerManager 就绪:时钟水位存共享 cache)
lifespan_setup.log_license_banner(app)
# 5. 插件加载(混合模式:inline 进程内加载;external 由独立进程运行,
# 其路由经 Zenoh 动态注册——进程编排交给 Docker/K8s,网关不拉起进程)
worker_tasks, custom_routers, inline_types, external_types = (
@@ -315,3 +324,4 @@ app.include_router(storage_router)
app.include_router(stats_router)
app.include_router(worker_admin_router)
app.include_router(auth_router)
app.include_router(license_router)
+142
View File
@@ -0,0 +1,142 @@
"""网关侧机器码授权门禁。
职责:把 `chongming_license` 的判定接进网关生命周期 —— 读配置、建管理器、
在插件发现阶段拒绝未授权插件、输出启动横幅。
**为什么"是否执行拒绝"不读配置开关**:部署目录的 `config.toml` 是明文可写的,
任何 `enabled = false` / `mode = "off"` 都能被一键绕开。因此以**打包形态**为准
(Nuitka/PyInstaller 产物 `sys.frozen`/`__compiled__`):打包即强制校验,且
改配置无法伪造打包与否。开发/测试(非打包)默认放行,需显式用
``[license] enabled = true`` 或 ``CHONGMING_LICENSE_MODE=on`` 打开才能验证门禁逻辑。
安全边界说明:授权信任根是**厂商私钥**(Ed25519 签名),公钥可明文放置 ——
换公钥只会验签失败,无法据以伪造。注意根仓 `resources/*.key` 那套 `.mbank`
验签是 HMAC **对称**且密钥随包发布,只能算防篡改,**不能**作为授权信任根。
"""
from __future__ import annotations
import os
import sys
from chongming_cache import Cache
from chongming_config import get_config
from chongming_license import (
DEFAULT_MIN_MATCH,
LicenseManager,
LicenseStatus,
PluginDecision,
)
from .logger import get_logger
logger = get_logger("license")
# 默认授权文件路径(相对网关工作目录,与 config/、data/ 同级)
DEFAULT_LICENSE_PATH = "license.lic"
# 强制开启/关闭的环境变量(仅非打包态生效;打包态恒为强制校验)
ENV_MODE = "CHONGMING_LICENSE_MODE"
_TRUTHY = {"on", "enforce", "enabled", "true", "1", "yes"}
_manager: LicenseManager | None = None
def is_packaged() -> bool:
"""当前是否运行在打包产物中(Nuitka / PyInstaller)。
Nuitka 在模块全局注入 ``__compiled__``,PyInstaller 设置 ``sys.frozen``。
"""
return bool(getattr(sys, "frozen", False) or globals().get("__compiled__"))
def license_conf() -> dict:
"""``[license]`` 配置段(缺省为空 dict)。"""
return get_config().get("license", {}) or {}
def should_enforce() -> bool:
"""是否真正执行拒绝。
打包产物 **恒为 True**(见模块文档);非打包态由配置/环境变量决定,
默认 False 以免影响现有开发流程与测试。
"""
conf = license_conf()
env_mode = os.environ.get(ENV_MODE, "").strip().lower()
if is_packaged():
if conf.get("enabled") is False or env_mode in ("off", "disable", "disabled", "0"):
# 陷阱日志:打包态却看到"关闭授权"的意图 —— 不予采纳,但要留痕
logger.warning(
"[license] 检测到关闭授权的配置/环境变量,但当前为打包产物,"
"授权校验强制开启(该配置不生效)。若确需关闭请联系研发。"
)
return True
if env_mode:
return env_mode in _TRUTHY
return bool(conf.get("enabled", False))
def _build_manager() -> LicenseManager:
conf = license_conf()
return LicenseManager(
license_path=str(conf.get("path") or DEFAULT_LICENSE_PATH),
public_key=str(conf.get("public_key") or ""),
min_match=int(conf.get("min_match", DEFAULT_MIN_MATCH)),
# 时钟水位存共享缓存(sqlite 本机 / redis 跨主机),不加 TTL:
# 水位一旦过期被淘汰,回拨检测就失效了
clock_store=Cache.from_config(get_config()["cache"]),
enforce=should_enforce(),
logger=logger,
)
def get_manager() -> LicenseManager:
"""授权管理器单例(惰性创建)。"""
global _manager
if _manager is None:
_manager = _build_manager()
return _manager
def reset_manager() -> None:
"""丢弃单例(测试用;换配置或换 license 文件后重新判定)。"""
global _manager
_manager = None
def reload() -> LicenseStatus:
"""重新读取 license 文件(管理接口导入新文件后调用)。"""
return get_manager().reload()
def check_plugin(plugin_type: str) -> PluginDecision:
"""该插件是否获授权运行。"""
return get_manager().check_plugin(plugin_type)
def denied_types(plugin_types) -> set[str]:
"""从给定插件类型里筛出未获授权的(即不允许加载的)。"""
return get_manager().denied_types(plugin_types)
def describe() -> dict:
"""给 ``GET /license/info`` 与前端「授权」页的完整信息。"""
data = get_manager().describe()
data["enforce_forced_by_packaging"] = is_packaged()
data["license_path"] = get_manager().license_path
return data
def log_banner() -> None:
"""启动横幅:机器码、授权状态、授权插件、公钥指纹。"""
try:
for line in get_manager().banner():
logger.info(line)
if get_manager().enforce and not get_manager().status.ok:
logger.error(
"[license] 未授权状态下所有插件均不会加载;"
"请在管理页「授权」导入 license 文件,或联系厂商签发(需提供本机机器码)。"
)
except Exception as exc: # 授权模块异常不应阻断启动流程
logger.error("[license] 授权状态检查异常: %s", exc)
+81 -2
View File
@@ -326,6 +326,16 @@ async def setup_route_registry(
registry = RouteRegistry(app, manager, prefix=prefix)
if inline_types:
registry.set_inline_types(set(inline_types))
# 授权门禁:未授权类型一律忽略其路由描述符。此处独立重算(不依赖 load_workers
# 的调用顺序),避免模块级状态带来的隐式时序耦合。
from .licensing import denied_types as _denied_types, get_manager as _get_license
if _get_license().enforce:
denied = _denied_types({p["type"] for p in _scan_plugins()})
registry.set_denied_types(denied)
if denied:
logger.warning(
"路由注册表已拒绝未授权插件描述符: %s", sorted(denied)
)
await registry.start(session)
app.state.route_registry = registry
logger.info(
@@ -335,9 +345,9 @@ async def setup_route_registry(
return registry
def _discover_plugins() -> list[dict]:
def _scan_plugins() -> list[dict]:
"""
发现所有插件配置。
扫描全部插件配置(**不做授权过滤**,授权门禁见 _discover_plugins)。
优先从各插件的独立配置文件 config/workers/<type>/config.toml 的 [workers] 节读取,
兼容旧的 config/workers/config.toml 全局配置。
@@ -422,6 +432,75 @@ def _discover_plugins() -> list[dict]:
return plugins
# 最近一次授权门禁的拒绝明细:插件类型 -> 拒绝原因
_denied_plugin_types: dict[str, str] = {}
def denied_plugin_types() -> dict[str, str]:
"""最近一次插件发现中被授权门禁拒绝的类型 -> 原因(供管理接口展示)。"""
return dict(_denied_plugin_types)
def log_license_banner(app: FastAPI | None = None) -> None:
"""启动横幅:机器码、授权状态、授权插件、公钥指纹。
在 WorkerManager 就绪后调用(时钟水位写入共享 cache)。异常不阻断启动 ——
授权模块自身出问题时仍应让网关起来并留下日志,而不是变成一个起不来的黑盒。
"""
from .licensing import log_banner
try:
log_banner()
except Exception as exc:
logger.error("[license] 授权状态横幅输出失败: %s", exc)
def _discover_plugins() -> list[dict]:
"""发现插件并应用**授权门禁**,返回可加载的插件列表。
这里是机器码授权的**唯一收口**:load_workers / validate_contracts /
register_routes(以及旧写法下的 MQTT 主题汇总)全部经由本函数取插件列表,
因此在此剔除未授权插件即可让三处自动保持一致。
必须收口于此而不是"跳过 import":契约校验(``validate_contracts``)会把
该插件的 TOML 路由视为"缺少 @rpc_method 注册"的硬性错误并**终止启动** ——
只跳过 import 会让设备直接起不来。在此剔除后,该插件连同其 TOML 路由、
自定义路由、MQTT 主题、副本数一起从启动流程中消失,契约校验自然跳过。
external 插件的路由由 RouteRegistry 动态挂载,不经本函数,故另有
``RouteRegistry.set_denied_types`` 封堵(否则把 load_mode 改成 external
即可绕过门禁)。
"""
plugins = _scan_plugins()
from .licensing import check_plugin, get_manager
if not get_manager().enforce:
# 未启用(开发/测试):不剔除任何插件
_denied_plugin_types.clear()
return plugins
allowed: list[dict] = []
denied: dict[str, str] = {}
for p in plugins:
decision = check_plugin(p["type"])
if decision.allowed:
allowed.append(p)
else:
denied[p["type"]] = decision.message
_denied_plugin_types.clear()
_denied_plugin_types.update(denied)
for plugin_type in sorted(denied):
logger.error("[license] %s", denied[plugin_type])
if denied:
logger.warning(
"授权门禁已拒绝 %d 个插件(不加载、不注册路由、管理接口禁止操作): %s",
len(denied), sorted(denied),
)
return allowed
async def load_workers(app: FastAPI, session: zenoh.Session, manager: WorkerManager,
is_inline_owner: bool = True):
"""
+28
View File
@@ -74,6 +74,10 @@ class RouteRegistry:
self._mqtt_bridge = None
# 网关进程内加载(inline)的插件类型:忽略其描述符,防止与盘扫注册重复挂载
self._inline_types: set = set()
# 被授权门禁拒绝的插件类型:忽略其描述符(未获授权的插件不得暴露任何 HTTP 端点)
self._denied_types: set = set()
# 已告警过的拒绝类型(避免 worker 周期性重发布描述符导致日志刷屏)
self._denied_logged: set = set()
# 将动态 schema 注入 OpenAPI(provider 实时读取本注册表)
_inject_schemas_to_openapi(
@@ -87,6 +91,20 @@ class RouteRegistry:
"""设置 inline 插件类型集合(混合模式防双注册)。"""
self._inline_types = set(types)
def set_denied_types(self, types: set):
"""设置被授权门禁拒绝的插件类型集合,其路由描述符一律忽略。
必要性:门禁在网关侧只能拦住"盘扫加载"这条路径。若把某插件 config.toml
的 ``load_mode`` 改成 ``external``,网关就不再加载它、``set_inline_types``
也不再抑制它,其独立进程推来的路由描述符就会被正常挂载 —— 等于绕过了
授权。因此这里对未授权类型再做一道拦截,且**与 load_mode 无关**。
局限(如实记录):这只拦截 HTTP 暴露,被直接拉起的 external 进程自身
仍会运行。彻底封堵需在 chongming-worker SDK 侧加门禁(另行规划)。
"""
self._denied_types = set(types)
self._denied_logged &= self._denied_types
async def start(self, session: zenoh.Session):
"""订阅 chongming/routes/**,开始接收路由描述符。"""
self._loop = asyncio.get_running_loop()
@@ -151,6 +169,16 @@ class RouteRegistry:
# 该插件由网关进程内加载(盘扫注册路由),忽略外部描述符防双注册
return
if wtype in self._denied_types:
# 授权门禁拒绝的插件:不下发任何 HTTP 端点(与 load_mode 无关)
if wtype not in self._denied_logged:
self._denied_logged.add(wtype)
logger.error(
"拒绝未授权插件 %s 的路由描述符(key=%s):该插件不在授权白名单内,"
"其端点不会被挂载", wtype, key,
)
return
if kind == zenoh.SampleKind.DELETE:
self._remove(wtype, route_id)
return
+8 -1
View File
@@ -2,5 +2,12 @@ from .storage import router as storage_router
from .stats import router as stats_router
from .worker_admin import router as worker_admin_router
from .auth import router as auth_router
from .licensing import router as license_router
__all__ = ["storage_router", "stats_router", "worker_admin_router", "auth_router"]
__all__ = [
"storage_router",
"stats_router",
"worker_admin_router",
"auth_router",
"license_router",
]
+208
View File
@@ -0,0 +1,208 @@
"""机器码授权管理端点(查看授权状态 / 展示机器码 / 导入 license 文件)。
设计取舍:
- **仅超级管理员可用**:机器码与各硬件来源指纹属设备标识信息,且导入 license 会
改变整个网关的插件加载行为,与 `worker_admin` 同级风险,统一按超管门禁。
设备上默认已 seed admin/admin,现场装机人员登录后即可取机器码,不额外加门槛。
- **导入先验证后落盘**:非法/验签失败/机器码不符的文件一律拒绝且不覆盖现有文件,
避免"上传了一个坏文件导致原本可用的授权也没了"。
- **导入不即时加载插件**:`_discover_plugins` 在启动期完成,新授权的插件要等
网关重启才会真正加载;响应里的 `restart_required` 如实告知,前端据此提示。
路径为 ``/api/v1/license/...``(跟随 ``[default] prefix``)。
"""
from __future__ import annotations
import json
import os
import shutil
import tempfile
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from chongming_config import get_config
from ..core import licensing
from ..core.auth import jwt_auth_dependency
from ..core.lifespan_setup import _scan_plugins, denied_plugin_types
from ..core.logger import get_logger
config = get_config()
logger = get_logger("license")
router = APIRouter(prefix=config["default"]["prefix"], tags=["license"])
# license 文件大小上限:正常文件仅数 KB,给足余量的同时避免被当作上传通道
MAX_LICENSE_BYTES = 64 * 1024
def require_superuser(
payload: dict = Depends(jwt_auth_dependency),
) -> dict:
"""仅超级管理员可访问(与 worker_admin 一致的门禁口径)。"""
if not payload.get("is_superuser"):
raise HTTPException(status_code=403, detail="需要超级管理员权限 (is_superuser)")
return payload
def _loaded_types(request: Request) -> set[str]:
"""当前已实际加载的插件类型(inline + external 观测集合)。"""
return set(getattr(request.app.state, "inline_types", None) or []) | set(
getattr(request.app.state, "external_types", None) or []
)
def _plugin_overview(request: Request) -> dict:
"""插件视角的授权全景:盘扫到的 / 已授权 / 被拒 / 当前已加载。"""
discovered = [p["type"] for p in _scan_plugins()]
manager = licensing.get_manager()
enforce = manager.enforce
licensed, denied = [], {}
for plugin_type in discovered:
decision = manager.check_plugin(plugin_type)
if decision.allowed:
licensed.append(plugin_type)
else:
denied[plugin_type] = {"code": decision.code, "message": decision.message}
return {
"discovered": sorted(discovered),
# 未启用校验时不存在"授权与否",如实按"全部可用"展示,避免误导
"licensed": sorted(licensed) if enforce else sorted(discovered),
"denied": denied if enforce else {},
"loaded": sorted(_loaded_types(request)),
"denied_from_last_scan": denied_plugin_types(),
}
@router.get("/license/info", summary="授权状态 / 机器码 / 申请码")
async def license_info(
request: Request,
_admin: dict = Depends(require_superuser),
):
"""返回本机机器码、授权状态、各插件授权情况与硬件来源明细。
现场申请授权时,把响应中的 ``request_code``(或 ``machine_code``)发给厂商即可。
"""
info = licensing.describe()
# 申请码:把各来源指纹打包成一段可抄写文本,方便直接贴进邮件 ——
# 只报机器码只能签出"精确匹配"的授权(无换硬件容错),故优先给申请码
request_code = ""
try:
from chongming_license import encode_request_code
request_code = encode_request_code()
except Exception as exc:
logger.warning("生成申请码失败:%s", exc)
overview = _plugin_overview(request)
return {
**info,
"request_code": request_code,
"plugins": overview,
}
@router.post("/license/reload", summary="重新读取授权文件")
async def license_reload(
_admin: dict = Depends(require_superuser),
):
"""从磁盘重新读取 license(手工替换文件后无需重启即可刷新状态)。
注意:插件是否加载仍以启动期判定为准,刷新状态不会加载新授权的插件。
"""
status = licensing.reload()
logger.info("授权文件已重新读取: %s", status.message)
return {"success": True, "status": status.as_dict()}
@router.post("/license/file", summary="导入 license 文件(覆盖现有授权)")
async def license_upload(
request: Request,
file: UploadFile = File(..., description="厂商签发的 license.lic"),
_admin: dict = Depends(require_superuser),
):
"""上传并安装 license 文件。
流程:读入 → 大小/JSON 校验 → **验签 + 机器码 + 有效期校验** → 通过才落盘
(临时文件 + 原子替换)→ 刷新授权状态。
未通过校验的文件一律拒绝,且**不会覆盖**现有可用授权 —— 现场误传坏文件不至于
把设备搞成未授权。校验通过但白名单含此前未授权的插件时,需**重启网关**才会
加载它们(`_discover_plugins` 只在启动期执行)。
"""
raw = await file.read()
if not raw:
raise HTTPException(status_code=422, detail="上传内容为空")
if len(raw) > MAX_LICENSE_BYTES:
raise HTTPException(
status_code=413,
detail=f"license 文件过大({len(raw)} 字节,上限 {MAX_LICENSE_BYTES})",
)
try:
json.loads(raw)
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"不是合法的 JSON:{exc}") from exc
manager = licensing.get_manager()
target = Path(manager.license_path)
# 先落到同目录的临时文件再校验:read_license 需要真实路径,
# 且同目录保证后续 move 是原子 rename(不会读到半截文件)
tmp_dir = target.parent if str(target.parent) else Path(".")
tmp_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=".license-", suffix=".tmp", dir=str(tmp_dir))
try:
with os.fdopen(fd, "wb") as f:
f.write(raw)
# 用与运行期完全相同的判定逻辑校验(含机器码与有效期)
from chongming_license import read_license
status = read_license(
tmp_name,
manager.public_key,
min_match=manager.min_match,
sources=manager.collect_sources(),
)
if not status.ok:
raise HTTPException(
status_code=422,
detail=f"授权文件校验未通过:{status.message}(原因码 {status.code})",
)
shutil.move(tmp_name, target)
tmp_name = "" # 已移走,finally 不再删除
os.chmod(target, 0o644)
finally:
if tmp_name and os.path.exists(tmp_name):
os.unlink(tmp_name)
after = licensing.reload()
logger.info(
"已导入授权文件 %s:%s(授权插件 %s)",
target, after.message, sorted(after.licensed_plugins) or "无",
)
# 本次授权后"已授权但尚未加载"的插件 —— 这些需要重启网关才会生效
pending = sorted(
(set(_plugin_overview(request)["discovered"]) & after.licensed_plugins)
- _loaded_types(request)
)
return {
"success": True,
"license_path": str(target),
"status": after.as_dict(),
"licensed_plugins": sorted(after.licensed_plugins),
"restart_required": bool(pending),
"pending_plugins": pending,
"restart_hint": (
f"以下插件已获授权但尚未加载,重启网关后生效:{pending}"
if pending else "无新增插件需要加载,无需重启。"
),
}
+48 -2
View File
@@ -19,6 +19,7 @@ from chongming_config import get_config
from ..core.auth import jwt_auth_dependency
from ..core.logger import get_logger
from ..core import licensing
config = get_config()
@@ -39,6 +40,26 @@ def require_admin(
return payload
def _ensure_licensed(worker_type: str) -> None:
"""授权门禁:未授权的插件禁止任何管理操作(扩缩容 / 重启 / 读改配置)。
未授权插件在启动期就不加载(见 lifespan_setup._discover_plugins),因此在
离线设备上通常表现为"不存在";但网关可能因设备重启后
license 到期、或被手动改过配置而留下残存副本,故此处必须独立拦截 ——
否则可以借扩缩容把未授权插件重新拉起、或经 config 修改其行为。
每个管理端点在入口显式调用(而非做成依赖),便于审计"哪些入口受门禁保护"。
"""
if not licensing.get_manager().enforce:
return
decision = licensing.check_plugin(worker_type)
if not decision.allowed:
raise HTTPException(
status_code=403,
detail=f"{decision.message};该插件不在授权白名单内,管理操作已拒绝",
)
def _get_manager(request: Request):
"""从应用状态获取 WorkerManager;未初始化时(lifespan 外)报 503。"""
manager = getattr(request.app.state, "worker_manager", None)
@@ -52,9 +73,27 @@ async def list_workers(
request: Request,
_admin: dict = Depends(require_admin),
):
"""返回全部插件实例(worker_type / worker_id / mode / alive),供前端渲染与选择。"""
"""返回全部插件实例(worker_type / worker_id / mode / alive),供前端渲染与选择。
每个实例附 `licensed` 标注:未授权插件即使有残存副本也不应被操作,
前端据此禁用按钮。
"""
manager = _get_manager(request)
return {"workers": manager.list_workers(), "success": True}
workers = manager.list_workers()
enforce = licensing.get_manager().enforce
for w in workers:
w["licensed"] = (
True if not enforce
else licensing.check_plugin(w["worker_type"]).allowed
)
return {
"workers": workers,
"success": True,
"license_enforced": enforce,
"denied_plugin_types": (
licensing.denied_types({w["worker_type"] for w in workers}) if enforce else set()
),
}
@router.post("/admin/workers/restart", summary="重启单个 Worker 副本")
@@ -69,6 +108,7 @@ async def restart_worker(
- worker_type: 插件类型(如 example)
- worker_id: 副本 id(如 worker-0-1-12345),由 GET /admin/workers 提供
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
try:
result = await manager.restart_worker(worker_type, worker_id)
@@ -103,6 +143,7 @@ async def restart_all(
注意:网关进程内加载的 inline 插件副本经本接口重启,仅重建 worker 协程,
不会卸载插件;external 插件同理逐个副本重启。
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
candidates = [
w for w in manager.list_workers()
@@ -146,6 +187,7 @@ async def scale_workers(
- inline 直接调整本进程 Worker 池;external 经 Zenoh 下发 rescale 到存活副本,
由其进程内池扩缩并写回自身配置。
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
try:
result = await manager.scale_workers(worker_type, body.count)
@@ -186,6 +228,7 @@ async def list_worker_instances(
每个实例 = 一个可独立承载副本的执行单元(inline=本网关进程/host,
external=独立进程/容器),含当前副本数与可选中的 worker_id。
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
try:
instances = await manager.list_instances(worker_type)
@@ -210,6 +253,7 @@ async def scale_worker_instance(
经该实例任一副本下发 rescale{scale, instance_id, absolute:true},由其进程内
WorkerPool 直接扩缩并记入逐实例覆盖表;重启后仍保持该绝对数。
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
try:
result = await manager.scale_instance(
@@ -256,6 +300,7 @@ async def read_worker_config(
- worker_type: 插件类型(如 example)
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
try:
info = await manager.get_worker_config(worker_type)
@@ -305,6 +350,7 @@ async def update_worker_config(
- worker_type: 插件类型(如 example)
- body.updates: 待更新的键值,如 {"data_dir": "./data2"}
"""
_ensure_licensed(worker_type)
manager = _get_manager(request)
if not body.updates:
raise HTTPException(status_code=422, detail="updates 不能为空")
+224
View File
@@ -0,0 +1,224 @@
"""构建期授权守护测试:公钥注入 + 私钥泄漏守卫。
对齐 `test_build_plugin.py` 对"插件包不得包含 secret_key"的同类断言 ——
**签发私钥绝不能进入部署产物**:它是授权的唯一信任根,一旦随包发布,
任何人都能签发授权,机器码绑定形同虚设。
运行方式:`python test/test_license_build.py`,或 `uv run pytest test/test_license_build.py`。
"""
import os
import sys
import tempfile
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "utils" / "cli" / "src"))
from chongming_cli.build import ( # noqa: E402
ALLOW_NO_KEY_ENV,
LICENSE_PUBKEY_ENV,
_resolve_license_public_key,
_set_license_pubkey,
_stage_license_guard,
)
failures: list[str] = []
def check(name, cond, detail=""):
status = "PASS" if cond else "FAIL"
print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
if not cond:
failures.append(name)
def test_pubkey_injection_is_section_scoped():
"""公钥只写进 [license] 段,注释与其它段不受影响。"""
src = (ROOT / "public" / "config.toml").read_text(encoding="utf-8")
out = _set_license_pubkey(src, "AAAABBBB=")
body = tomllib.loads(out)
check("注入后 TOML 可解析", body["license"]["public_key"] == "AAAABBBB=",
str(body["license"].get("public_key")))
# 只改了 [license] 段内那一行
lic_start = out.index("[license]")
prod_start = out.index("[production]")
check("公钥位于 [license] 段内",
'public_key = "AAAABBBB="' in out[lic_start:prod_start])
check("[production] 段未被误改", "public_key" not in out[prod_start:])
check("注释保留", "# 机器码授权" in out)
check("其它段结构完整", "[production.module_system]" in out)
check("[license] 段仍保留 path/min_match",
body["license"]["path"] == "license.lic" and body["license"]["min_match"] == 3)
diff = [i for i, (a, b) in enumerate(zip(src.splitlines(), out.splitlines())) if a != b]
check("仅一行发生变化", len(diff) == 1, f"变化行号={diff}")
def test_pubkey_resolution_order():
"""公钥解析优先级:环境变量 > 文件环境变量 > 默认文件 license_public.key。
必须在**空的临时目录**里跑:默认回退路径 ``license_public.key`` 是相对 CWD 的,
而厂商在项目根放过这个文件是正常用法(也正是构建取公钥的正规途径),
测试不能依赖它"不存在"。
"""
tmp = Path(tempfile.mkdtemp())
key_file = tmp / "pk.txt"
key_file.write_text("FROMFILE", encoding="utf-8")
old_cwd = os.getcwd()
old_env = dict(os.environ)
try:
os.chdir(tmp) # 空目录:既无 license_public.key 也无其它干扰
for var in (LICENSE_PUBKEY_ENV, "CHONGMING_LICENSE_PUBKEY_FILE"):
os.environ.pop(var, None)
check("三者都无时返回空", _resolve_license_public_key() == "")
# 默认文件回退(构建按此取公钥,之前漏测)
(tmp / "license_public.key").write_text("FROMDEFAULT", encoding="utf-8")
check("回退到默认文件 license_public.key",
_resolve_license_public_key() == "FROMDEFAULT")
os.environ["CHONGMING_LICENSE_PUBKEY_FILE"] = str(key_file)
check("文件环境变量优先于默认文件",
_resolve_license_public_key() == "FROMFILE")
os.environ[LICENSE_PUBKEY_ENV] = "FROMENV"
check("环境变量优先于文件", _resolve_license_public_key() == "FROMENV")
finally:
os.chdir(old_cwd)
os.environ.clear()
os.environ.update(old_env)
def test_guard_detects_private_key():
"""守护能检出私钥(按内容,与扩展名无关,含跨块边界)。"""
tmp = Path(tempfile.mkdtemp())
build = tmp / "build"
(build / "sub").mkdir(parents=True)
old_cwd = os.getcwd()
try:
os.chdir(tmp)
(build / "ok.txt").write_text("正常内容\n", encoding="utf-8")
check("干净产物通过", _stage_license_guard() == "未发现私钥泄漏")
# 证书类 .pem 是合法的构建内容,不应被误判
(build / "cert.pem").write_text(
"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n", encoding="utf-8"
)
check("合法证书 .pem 不误判", _stage_license_guard() == "未发现私钥泄漏")
# 私钥改名藏进普通文件 -> 按内容仍能检出
leak = build / "sub" / "innocent.txt"
leak.write_text("-----BEGIN PRIVATE KEY-----\nMIIEvQ\n", encoding="utf-8")
try:
_stage_license_guard()
check("按内容检出改名的私钥", False, "未检出")
except RuntimeError as exc:
check("按内容检出改名的私钥", "innocent.txt" in str(exc))
leak.unlink()
# 跨 1MB 块边界的私钥
(build / "big.bin").write_bytes(
b"x" * (1 << 20) + b"-----BEGIN RSA PRIVATE KEY-----" + b"y" * (1 << 20)
)
try:
_stage_license_guard()
check("检出跨块边界的私钥", False, "未检出")
except RuntimeError as exc:
check("检出跨块边界的私钥", "big.bin" in str(exc))
(build / "big.bin").unlink()
# 文件名叫 license_private* 的一律拦下
(build / "license_private.pem").write_text("(truncated)", encoding="utf-8")
try:
_stage_license_guard()
check("按文件名拦下授权私钥文件", False, "未检出")
except RuntimeError as exc:
check("按文件名拦下授权私钥文件", "license_private" in str(exc))
finally:
os.chdir(old_cwd)
def test_build_fails_without_pubkey():
"""打包时缺少公钥 -> 构建失败(避免产出拒绝所有插件的砖头包)。"""
import chongming_cli.build as build_mod
tmp = Path(tempfile.mkdtemp())
old_cwd = os.getcwd()
old_env = dict(os.environ)
try:
# 复刻 build/ 最小结构:_stage_prepare_build_dir 需要 public/ 与 config.toml
(tmp / "public" / "utils").mkdir(parents=True)
(tmp / "public" / "main.py").write_text("", encoding="utf-8")
(tmp / "public" / "server.py").write_text("", encoding="utf-8")
(tmp / "public" / "config.toml").write_text(
(ROOT / "public" / "config.toml").read_text(encoding="utf-8"), encoding="utf-8"
)
os.chdir(tmp)
for var in (LICENSE_PUBKEY_ENV, "CHONGMING_LICENSE_PUBKEY_FILE", ALLOW_NO_KEY_ENV):
os.environ.pop(var, None)
try:
build_mod._stage_prepare_build_dir({}, "pyinstaller") # type: ignore[arg-type]
check("缺少公钥时构建失败", False, "未抛异常")
except RuntimeError as exc:
check("缺少公钥时构建失败", LICENSE_PUBKEY_ENV in str(exc))
# 显式放行时才允许无公钥构建
os.environ[ALLOW_NO_KEY_ENV] = "1"
build_mod._stage_prepare_build_dir({}, "pyinstaller") # type: ignore[arg-type]
built = tomllib.loads((tmp / "build" / "config.toml").read_text(encoding="utf-8"))
check("显式放行时构建成功且公钥为空", built["license"]["public_key"] == "")
# 提供公钥后应注入到产物配置
os.environ[LICENSE_PUBKEY_ENV] = "INJECTEDKEY="
build_mod._stage_prepare_build_dir({}, "pyinstaller") # type: ignore[arg-type]
built = tomllib.loads((tmp / "build" / "config.toml").read_text(encoding="utf-8"))
check("公钥被注入产物配置", built["license"]["public_key"] == "INJECTEDKEY=")
check("产物配置仍含 min_match", built["license"]["min_match"] == 3)
finally:
os.chdir(old_cwd)
os.environ.clear()
os.environ.update(old_env)
def test_mbank_signing_is_not_a_trust_root():
""".mbank 验签用随包发布的对称密钥 —— 不能作为授权信任根。
本测试固化这一认知:只要 sqlite_vfs 的 sign 仍是 HMAC,就说明它只能算防篡改,
授权必须依赖非对称签名(chongming_license 的 Ed25519)。
"""
import inspect
from sqlite_vfs import core as vfs_core
src = inspect.getsource(vfs_core.SQLiteVFS.sign)
check("确认 .mbank 签名为 HMAC(对称)", "hmac.new" in src,
"若已改为非对称,可更新本测试与文档说明")
check("确认验证端同样是对称 HMAC",
"hmac.new" in inspect.getsource(vfs_core.SQLiteVFS.verify_signature))
def main():
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for fn in tests:
print(f"\n--- {fn.__name__} ---")
fn()
print("\n" + "=" * 56)
if failures:
print(f"❌ {len(failures)} 项未通过: {failures}")
return 1
print("✅ 全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())
+377
View File
@@ -0,0 +1,377 @@
"""机器码授权门禁测试(网关侧)。
核心验证目标 —— 未授权插件被拒绝时,**网关必须仍能正常启动**:
1. 门禁收口在 `_discover_plugins()`:未授权插件不出现在返回列表中,
且 `denied_plugin_types()` 能给出拒绝原因;
2. **契约校验不硬失败**:这是最容易踩的坑 —— `validate_contracts()` 会把
"TOML 路由没有对应 @rpc_method 注册"视为硬性错误并终止启动。若把"不加载
未授权插件"实现成"跳过 import 但保留在插件列表里",该插件的 TOML 路由就会
变成孤儿 → 设备起不来。本测试确保收口位置正确。
3. 路由描述符门禁:未授权类型的 external 路由描述符被忽略(防"改 load_mode
成 external 绕过门禁");
4. 管理接口门禁:未授权插件的扩缩容/重启/配置接口一律 403;
5. 未启用校验时(开发/测试)全部放行。
运行方式:`python test/test_license_gate.py`,或 `uv run pytest test/test_license_gate.py`。
"""
import asyncio
import base64
import json
import os
import sys
import tempfile
from datetime import date, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# 必须在导入网关模块前设置:licensing 的 enforce 在首次建管理器时读取
os.environ["CHONGMING_LICENSE_MODE"] = "enforce"
from chongming_config import get_config # noqa: E402
from cryptography.hazmat.primitives import serialization # noqa: E402
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: E402
from chongming_license import collect_fingerprints, machine_code, sign_payload # noqa: E402
from chongming.app.core import licensing # noqa: E402
from chongming.app.core.lifespan_setup import ( # noqa: E402
_discover_plugins,
_scan_plugins,
denied_plugin_types,
)
failures: list[str] = []
def check(name, cond, detail=""):
status = "PASS" if cond else "FAIL"
print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
if not cond:
failures.append(name)
# ---------------------------------------------------------------------------
# 测试夹具:临时密钥对 + license 文件
# ---------------------------------------------------------------------------
def _make_keypair() -> tuple[str, Ed25519PrivateKey]:
priv = Ed25519PrivateKey.generate()
pub = base64.b64encode(
priv.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
).decode("ascii")
return pub, priv
def _issue(tmpdir: Path, priv: Ed25519PrivateKey, plugins: list[str],
expires_days: int = 365, sources: dict | None = None) -> str:
src = sources if sources is not None else collect_fingerprints()
payload = {
"v": 1,
"lic_id": "L-TEST-0001",
"customer": "测试客户",
"machine_code": machine_code(src),
"sources": src,
"plugins": plugins,
"not_before": None,
"expires_at": (date.today() + timedelta(days=expires_days)).isoformat(),
"issued_at": "2026-09-20T00:00:00Z",
"note": "",
}
doc = {
"payload": payload,
"alg": "ed25519",
"kid": "test",
"sig": sign_payload(payload, priv),
}
path = tmpdir / "license.lic"
path.write_text(json.dumps(doc), encoding="utf-8")
return str(path)
def _use_license(public_key: str, path: str, min_match: int = 3) -> None:
"""把测试用的公钥/路径写进运行期配置并重建管理器。
直接改 `get_config()` 返回的 dict(chongming_config 缓存同一对象)。
"""
cfg = get_config()
cfg.setdefault("license", {})
cfg["license"]["public_key"] = public_key
cfg["license"]["path"] = path
cfg["license"]["min_match"] = min_match
licensing.reset_manager()
def test_gate_denies_unlicensed_plugins():
"""未授权插件被剔除,且原因可查。"""
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
_use_license(pub, _issue(tmp, priv, plugins=["ip_sound_column"]))
scanned = sorted(p["type"] for p in _scan_plugins())
allowed = sorted(p["type"] for p in _discover_plugins())
denied = denied_plugin_types()
check("盘扫到全部插件", scanned == ["aibox_sophon_adapter", "ip_sound_column"],
f"scanned={scanned}")
check("只放行已授权插件", allowed == ["ip_sound_column"], f"allowed={allowed}")
check("未授权插件给出拒绝原因",
"aibox_sophon_adapter" in denied
and "白名单" in denied["aibox_sophon_adapter"],
str(denied))
def test_contract_validation_survives_denial():
"""关键:有插件被拒时契约校验不得抛异常(否则设备起不来)。"""
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
_use_license(pub, _issue(tmp, priv, plugins=["ip_sound_column"]))
from chongming.app.core.contracts import validate_contracts
inline = [p for p in _discover_plugins() if p.get("load_mode", "inline") != "external"]
try:
validate_contracts(inline)
check("被拒插件的 TOML 路由不会导致契约校验硬失败", True)
except Exception as exc: # noqa: BLE001
check("被拒插件的 TOML 路由不会导致契约校验硬失败", False, repr(exc))
def test_all_denied_still_validates():
"""极端情况:完全未授权(无 license 文件)时启动流程仍不硬失败。"""
tmp = Path(tempfile.mkdtemp())
pub, _priv = _make_keypair()
_use_license(pub, str(tmp / "not-exists.lic"))
from chongming.app.core.contracts import validate_contracts
allowed = _discover_plugins()
check("无授权文件时不放行任何插件", allowed == [], str(allowed))
try:
validate_contracts([])
check("完全未授权时契约校验不抛异常", True)
except Exception as exc: # noqa: BLE001
check("完全未授权时契约校验不抛异常", False, repr(exc))
def test_route_registry_denies_descriptors():
"""未授权类型的路由描述符被忽略(防改 load_mode 绕过)。"""
from fastapi import FastAPI
from chongming.app.core.route_registry import RouteRegistry
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
_use_license(pub, _issue(tmp, priv, plugins=["ip_sound_column"]))
denied = licensing.denied_types({p["type"] for p in _scan_plugins()})
check("denied_types 含未授权插件", "aibox_sophon_adapter" in denied, str(denied))
async def run():
registry = RouteRegistry(FastAPI(), worker_manager=None, prefix="/api/v1")
registry.set_denied_types(denied)
descriptor = {
"schema_version": 1, "type": "aibox_sophon_adapter",
"subject": "aibox_sophon_adapter.alarm_report", "method": "POST",
"path": "/aibox_sophon_adapter/alarm", "params": {},
}
await registry._handle_sample(
"chongming/routes/aibox_sophon_adapter/alarm_report",
__import__("zenoh").SampleKind.PUT,
json.dumps(descriptor).encode(),
)
return registry._groups, registry.route_types
groups, route_types = asyncio.run(run())
check("未授权类型的描述符未进入路由表", groups == {}, f"groups={list(groups)}")
check("未授权类型不出现在 route_types 中", route_types == [], str(route_types))
def test_admin_gate_403_for_denied():
"""未授权插件的管理操作一律 403。"""
from fastapi import HTTPException
from chongming.app.routes.worker_admin import _ensure_licensed
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
_use_license(pub, _issue(tmp, priv, plugins=["ip_sound_column"]))
try:
_ensure_licensed("aibox_sophon_adapter")
check("未授权插件的管理操作被拒", False, "未抛 403")
except HTTPException as exc:
check("未授权插件的管理操作被拒", exc.status_code == 403,
f"status={exc.status_code}")
try:
_ensure_licensed("ip_sound_column")
check("已授权插件的管理操作放行", True)
except HTTPException as exc: # noqa: BLE001
check("已授权插件的管理操作放行", False, f"status={exc.status_code}")
def test_expired_license_denies_all():
"""过期授权 -> 全部拒绝。"""
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
_use_license(pub, _issue(tmp, priv, plugins=["ip_sound_column", "aibox_sophon_adapter"],
expires_days=-1))
allowed = _discover_plugins()
check("过期授权不放行任何插件", allowed == [], str(allowed))
check("拒绝原因为过期",
all("过期" in m for m in denied_plugin_types().values()),
str(denied_plugin_types()))
def test_disabled_mode_allows_everything():
"""未启用校验时(非打包态的默认行为)全部放行。"""
tmp = Path(tempfile.mkdtemp())
pub, priv = _make_keypair()
_use_license(pub, _issue(tmp, priv, plugins=[]))
licensing.reset_manager()
os.environ.pop("CHONGMING_LICENSE_MODE", None)
get_config()["license"]["enabled"] = False
try:
allowed = sorted(p["type"] for p in _discover_plugins())
check("未启用校验时全部放行",
allowed == ["aibox_sophon_adapter", "ip_sound_column"], str(allowed))
check("未启用校验时无拒绝记录", denied_plugin_types() == {},
str(denied_plugin_types()))
finally:
os.environ["CHONGMING_LICENSE_MODE"] = "enforce"
get_config()["license"]["enabled"] = False
licensing.reset_manager()
def test_http_endpoints_and_upload_safety():
"""HTTP 层:门禁、信息接口、以及"坏文件不覆盖现有授权"。
这一步覆盖最容易在现场出事的行为 —— 误传一个坏 license 文件把设备搞成未授权。
"""
import base64 as _b64
import json as _json
import tempfile as _tf
from datetime import date as _date, timedelta as _td
import orjson
from cryptography.hazmat.primitives import serialization as _ser
from fastapi.testclient import TestClient
from chongming_jwt import JWT
from chongming_license import collect_fingerprints as _collect
from chongming.app import app
tmp = Path(_tf.mkdtemp())
priv = Ed25519PrivateKey.generate()
pub = _b64.b64encode(
priv.public_key().public_bytes(
encoding=_ser.Encoding.Raw, format=_ser.PublicFormat.Raw
)
).decode("ascii")
src = _collect()
payload = {
"v": 1, "lic_id": "L-HTTP-1", "customer": "HTTP 测试",
"machine_code": machine_code(src), "sources": src,
"plugins": ["ip_sound_column"], "not_before": None,
"expires_at": (_date.today() + _td(days=30)).isoformat(),
"issued_at": "2026-09-20T00:00:00Z", "note": "",
}
lic = tmp / "license.lic"
lic.write_text(_json.dumps({
"payload": payload, "alg": "ed25519", "kid": "test",
"sig": sign_payload(payload, priv),
}), encoding="utf-8")
_use_license(pub, str(lic))
sec = get_config()["security"]
def _token(superuser: bool) -> str:
return JWT(sec["secret_key"], sec["algorithm"]).encode(
orjson.dumps({"sub": "u1", "username": "admin",
"is_superuser": superuser}).decode(),
expires_in_seconds=3600,
)
client = TestClient(app)
auth = {"Authorization": f"Bearer {_token(True)}"}
r = client.get("/api/v1/license/info")
check("无 token 时拒绝访问", r.status_code == 401, f"status={r.status_code}")
r = client.get("/api/v1/license/info",
headers={"Authorization": f"Bearer {_token(False)}"})
check("非超管拒绝访问", r.status_code == 403, f"status={r.status_code}")
r = client.get("/api/v1/license/info", headers=auth)
check("超管可读取授权信息", r.status_code == 200, f"status={r.status_code}")
info = r.json()
check("/license/info 含机器码", info["status"]["machine_code"] != "",
info["status"]["machine_code"])
check("/license/info 含申请码", info["request_code"].startswith("CM1-"),
info["request_code"][:16])
check("未授权插件在 denied 中", "aibox_sophon_adapter" in info["plugins"]["denied"],
str(list(info["plugins"]["denied"])))
check("来源明细含 weak 标注",
any(s["weak"] for s in info["sources_detail"]),
str([(s["name"], s["weak"]) for s in info["sources_detail"]]))
# 签名错误的文件:必须拒绝,且不得覆盖现有授权
tampered = dict(payload, plugins=["aibox_sophon_adapter"])
r = client.post(
"/api/v1/license/file", headers=auth,
files={"file": ("bad.lic", _json.dumps(
{"payload": tampered, "alg": "ed25519", "kid": "test", "sig": "AAAA"}
), "application/json")},
)
check("签名错误的文件被拒绝", r.status_code == 422, f"status={r.status_code}")
check("被拒文件未覆盖现有授权",
_json.loads(lic.read_text(encoding="utf-8"))["payload"]["lic_id"] == "L-HTTP-1")
# 非 JSON 文件
r = client.post("/api/v1/license/file", headers=auth,
files={"file": ("bad.lic", "not json at all", "application/json")})
check("非 JSON 文件被拒绝", r.status_code == 422, f"status={r.status_code}")
# 合法文件:安装成功,并提示需重启加载
payload2 = dict(payload, lic_id="L-HTTP-2",
plugins=["ip_sound_column", "aibox_sophon_adapter"])
r = client.post(
"/api/v1/license/file", headers=auth,
files={"file": ("good.lic", _json.dumps(
{"payload": payload2, "alg": "ed25519", "kid": "test",
"sig": sign_payload(payload2, priv)}
), "application/json")},
)
check("合法文件导入成功", r.status_code == 200, f"status={r.status_code}")
if r.status_code == 200:
body = r.json()
check("导入后授权插件更新",
body["licensed_plugins"] == ["aibox_sophon_adapter", "ip_sound_column"],
str(body["licensed_plugins"]))
check("导入后提示需重启加载",
body["restart_required"] and "aibox_sophon_adapter" in body["pending_plugins"],
str(body["pending_plugins"]))
def main():
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for fn in tests:
print(f"\n--- {fn.__name__} ---")
fn()
print("\n" + "=" * 56)
if failures:
print(f"❌ {len(failures)} 项未通过: {failures}")
return 1
print("✅ 全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())
+327
View File
@@ -0,0 +1,327 @@
"""已签发授权台账测试(厂商侧 Registry)。
重点验证:
1. 由 license 文档派生记录(唯一写入口,不手工拼记录)
2. 状态判定(永久/有效/即将到期/已过期/未生效)与剩余天数
3. 检索:客户 / 机器码 / 插件 / 关键字 / 状态
4. **续期复用台账里的来源指纹** —— 台账最大的价值:不必再向客户索取申请码
5. 重新导入不覆盖人工维护的备注
6. 导出 .lic(按客户分子目录)与 CSV(UTF-8 BOM,Excel 中文不乱码)
7. 结构版本不匹配时明确报错,而不是让查询诡异失败
运行:`python test/test_license_registry.py`,或 `uv run pytest test/test_license_registry.py`。
"""
import csv
import sqlite3
import sys
import tempfile
from datetime import date, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: E402
from chongming_license import ( # noqa: E402
Registry,
RegistryError,
build_license,
build_payload,
machine_code,
public_key_b64,
public_key_fingerprint,
record_from_license,
)
failures: list[str] = []
def check(name, cond, detail=""):
print(f"[{'PASS' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
if not cond:
failures.append(name)
PRIV = Ed25519PrivateKey.generate()
PUB = public_key_b64(PRIV)
FP = public_key_fingerprint(PUB)
SRC = {"board_serial": "sn123456789", "emmc_cid": "1a2b3c4d", "mac": "aabbccddeeff"}
CODE = machine_code(SRC)
def make_doc(*, customer="某某科技", days=300, plugins=("aibox_sophon_adapter",),
note="", machine=None, sources=None, lic_id="", not_before=None):
payload = build_payload(
machine_code=machine or CODE,
plugins=list(plugins),
sources=SRC if sources is None else sources,
customer=customer,
note=note,
lic_id=lic_id,
not_before=not_before,
expires_at=(date.today() + timedelta(days=days)) if days is not None else None,
)
return build_license(payload, PRIV, kid="vendor-2026")
def test_record_derivation():
"""记录完全由 license 文档派生,查询列与原始文档不会不一致。"""
doc = make_doc(customer="派生测试", note="合同 X", plugins=["a", "b"])
rec = record_from_license(doc, public_key_fp=FP, source="test")
payload = doc["payload"]
check("lic_id 取自载荷", rec.lic_id == payload["lic_id"], rec.lic_id)
check("机器码大写归一", rec.machine_code == payload["machine_code"].upper())
check("插件列表解析", rec.plugins == ["a", "b"], str(rec.plugins))
check("来源指纹解析", rec.sources == SRC, str(sorted(rec.sources)))
check("kid 取自信封", rec.kid == "vendor-2026")
check("公钥指纹记录在案", rec.public_key_fp == FP)
check("可还原完整文档", rec.license_doc()["sig"] == doc["sig"])
# 日期只保留日期部分:否则 SQL 里的字符串比较会得出错误排序
doc2 = make_doc()
doc2["payload"]["expires_at"] = "2027-09-01T12:34:56Z"
check("日期裁剪到日粒度",
record_from_license(doc2).expires_at == "2027-09-01",
str(record_from_license(doc2).expires_at))
# 缺 lic_id 必须报错,而不是静默写一条垃圾记录
try:
record_from_license({"payload": {"machine_code": "X"}})
check("缺 lic_id 时报错", False, "未抛异常")
except RegistryError:
check("缺 lic_id 时报错", True)
def test_status_and_days_left():
"""五种状态的判定(含边界:到期当天仍有效)。"""
tmp = Path(tempfile.mkdtemp())
with Registry(tmp / "r.db") as reg:
reg.save(make_doc(lic_id="L-FOREVER", days=None), public_key_fp=FP)
reg.save(make_doc(lic_id="L-ACTIVE", days=300), public_key_fp=FP)
reg.save(make_doc(lic_id="L-EXPIRING", days=10), public_key_fp=FP)
reg.save(make_doc(lic_id="L-EXPIRED", days=-1), public_key_fp=FP)
reg.save(make_doc(lic_id="L-TODAY", days=0), public_key_fp=FP)
reg.save(make_doc(lic_id="L-FUTURE", days=100,
not_before=(date.today() + timedelta(days=30)).isoformat()),
public_key_fp=FP)
got = {r.lic_id: r.status() for r in reg.query()}
check("无到期日 → forever", got["L-FOREVER"] == "forever", got["L-FOREVER"])
check("远期 → active", got["L-ACTIVE"] == "active", got["L-ACTIVE"])
check("30 天内 → expiring", got["L-EXPIRING"] == "expiring", got["L-EXPIRING"])
check("已过 → expired", got["L-EXPIRED"] == "expired", got["L-EXPIRED"])
check("到期当天仍有效(含当天)", got["L-TODAY"] == "expiring", got["L-TODAY"])
check("未到生效日 → not_yet_valid",
got["L-FUTURE"] == "not_yet_valid", got["L-FUTURE"])
check("剩余天数为负表示已过期",
reg.get("L-EXPIRED").days_left() < 0,
str(reg.get("L-EXPIRED").days_left()))
check("永久授权剩余天数为 None",
reg.get("L-FOREVER").days_left() is None)
st = reg.stats()
check("概览计数一致",
st == {"total": 6, "expired": 1, "expiring": 2, "active": 1,
"forever": 1, "not_yet_valid": 1}, str(st))
def test_query_filters():
"""检索:客户 / 机器码 / 插件 / 关键字 / 状态,以及排序。"""
tmp = Path(tempfile.mkdtemp())
with Registry(tmp / "r.db") as reg:
reg.save(make_doc(lic_id="L-A", customer="阿尔法科技", days=300,
plugins=["doubao"], note="HT-001"), public_key_fp=FP)
reg.save(make_doc(lic_id="L-B", customer="贝塔公司", days=10,
plugins=["ip_sound_column"], note="HT-002"), public_key_fp=FP)
reg.save(make_doc(lic_id="L-C", customer="贝塔公司", days=200,
plugins=["doubao_extended"], note="HT-003"), public_key_fp=FP)
other = "AAAA-BBBB-CCCC-DDDD-EEEE-FFFF"
reg.save(make_doc(lic_id="L-D", customer="伽马", days=300, machine=other,
sources={"mac": "aabbccddeeff"}), public_key_fp=FP)
check("按客户模糊匹配", {r.lic_id for r in reg.query(customer="贝塔")} == {"L-B", "L-C"})
# L-C 装的是 doubao_extended,查 doubao 时不应被命中(前缀同名不算同一插件)
check("按插件匹配不误伤前缀同名",
{r.lic_id for r in reg.query(plugin="doubao")} == {"L-A"},
str([r.lic_id for r in reg.query(plugin="doubao")]))
check("按插件匹配到前缀同名者本身",
{r.lic_id for r in reg.query(plugin="doubao_extended")} == {"L-C"})
check("按机器码匹配", {r.lic_id for r in reg.query(machine_code=CODE[:16])}
== {"L-A", "L-B", "L-C"})
check("按备注关键字匹配", {r.lic_id for r in reg.query(keyword="HT-002")} == {"L-B"})
check("大小写不敏感(机器码)",
{r.lic_id for r in reg.query(machine_code=other.lower())} == {"L-D"})
order = [r.lic_id for r in reg.query()]
check("按到期日升序(急的在前)", order[0] == "L-B", str(order))
# 某台机器的全部历史授权
check("按机器码取全部历史",
len(reg.find_by_machine_code(CODE)) == 3,
str(len(reg.find_by_machine_code(CODE))))
check("limit 生效", len(reg.query(limit=2)) == 2)
def test_renew_reuses_sources_without_request_code():
"""核心能力:续期复用台账里的指纹,无需再向客户索取申请码。"""
tmp = Path(tempfile.mkdtemp())
with Registry(tmp / "r.db") as reg:
old = reg.save(make_doc(lic_id="L-OLD", customer="续期客户", days=5,
plugins=["aibox_sophon_adapter", "ip_sound_column"],
note="合同 HT-100"), public_key_fp=FP)
check("原授权即将到期", old.status() == "expiring", old.status())
# 模拟现场:客户已联系不上,只有台账记录可用
renewed_doc = make_doc(lic_id="L-NEW", customer=old.customer, days=365,
plugins=old.plugins, note=old.note,
machine=old.machine_code, sources=old.sources)
new = reg.save(renewed_doc, public_key_fp=FP, source="renew")
check("续期成功", new.status() == "active", new.status())
check("指纹与原件一致(未索取新申请码)", new.sources == old.sources)
check("机器码不变", new.machine_code == old.machine_code)
check("新旧授权都在台账(可追溯)",
{r.lic_id for r in reg.find_by_machine_code(old.machine_code)}
== {"L-OLD", "L-NEW"})
def test_note_preserved_on_reimport():
"""重新导入同一份 license 不应覆盖人工维护的备注。"""
tmp = Path(tempfile.mkdtemp())
with Registry(tmp / "r.db") as reg:
doc = make_doc(lic_id="L-N", note="原始备注")
reg.save(doc, public_key_fp=FP)
check("备注已入库", reg.get("L-N").note == "原始备注")
reg.update_note("L-N", "人工补充:已电话确认续期")
check("备注可更新", reg.get("L-N").note == "人工补充:已电话确认续期")
created = reg.get("L-N").created_at
reg.save(doc, public_key_fp=FP) # 重新导入(载荷里还是旧备注)
after = reg.get("L-N")
check("重新导入不覆盖人工备注",
after.note == "人工补充:已电话确认续期", after.note)
check("创建时间保持不变", after.created_at == created, after.created_at)
check("更新时间被推进", after.updated_at >= created)
def test_export_and_csv():
"""导出 .lic(可按客户分目录)与 CSV(UTF-8 BOM,Excel 不乱码)。"""
tmp = Path(tempfile.mkdtemp())
with Registry(tmp / "r.db") as reg:
reg.save(make_doc(lic_id="L-X1", customer="甲方/科技", days=100), public_key_fp=FP)
reg.save(make_doc(lic_id="L-X2", customer="乙方", days=-3), public_key_fp=FP)
files = reg.export_licenses(tmp / "arc", subdir_by_customer=True)
check("导出数量正确", len(files) == 2, str([f.name for f in files]))
check("客户名含非法字符时安全替换",
any("甲方" in str(f) for f in files), str([str(f.parent.name) for f in files]))
# 导出的文件应能被校验端直接读取(验证导出内容不是"看着像"而已)
# 注意顺序按到期日升序:files[0] 是已过期的 L-X2,files[1] 是有效的 L-X1
from chongming_license import read_license
valid_file = next(f for f in files if "L-X1" in f.name)
expired_file = next(f for f in files if "L-X2" in f.name)
st = read_license(valid_file, PUB, sources=SRC)
check("导出的有效授权仍可验签通过", st.ok, st.message)
st2 = read_license(expired_file, PUB, sources=SRC)
check("导出的已过期授权被判定为过期(到期日完整保留)",
not st2.ok and st2.code == "expired", f"{st2.code} / {st2.message}")
check("导出内容可原样还原",
valid_file.read_text(encoding="utf-8").lstrip().startswith("{"))
csv_path = reg.export_csv(tmp / "台账.csv")
with csv_path.open(encoding="utf-8-sig", newline="") as f:
rows = list(csv.reader(f))
check("CSV 含表头 + 两条数据", len(rows) == 3, str(len(rows)))
check("CSV 表头含状态与到期日",
"状态" in rows[0] and "到期日期" in rows[0], str(rows[0]))
check("CSV 记录了状态文案",
any("已过期" in r for r in rows[1:]), str(rows[1:]))
check("CSV 以 UTF-8 BOM 写入(Excel 中文不乱码)",
csv_path.read_bytes()[:3] == b"\xef\xbb\xbf")
def test_schema_version_mismatch_is_explicit():
"""结构版本不匹配时明确报错,而不是让后续查询诡异失败。"""
tmp = Path(tempfile.mkdtemp())
path = tmp / "r.db"
with Registry(path) as reg:
reg.save(make_doc(lic_id="L-V"), public_key_fp=FP)
# 手工把版本改成未来值,模拟"被更新版本的工具写过"
conn = sqlite3.connect(str(path))
conn.execute("PRAGMA user_version = 99")
conn.commit()
conn.close()
try:
Registry(path)
check("版本不匹配时拒绝打开", False, "未抛异常")
except RegistryError as exc:
check("版本不匹配时拒绝打开", "99" in str(exc), str(exc).splitlines()[0])
# 已存在但 user_version=0 的库(旧文件)应被平滑接管
path2 = tmp / "old.db"
conn = sqlite3.connect(str(path2))
conn.executescript(
"CREATE TABLE issued_licenses (lic_id TEXT PRIMARY KEY, machine_code TEXT, "
"customer TEXT, note TEXT, plugins_json TEXT, sources_json TEXT, not_before TEXT, "
"expires_at TEXT, issued_at TEXT, kid TEXT, public_key_fp TEXT, license_json TEXT, "
"source TEXT, created_at TEXT, updated_at TEXT);"
)
conn.commit()
conn.close()
with Registry(path2) as reg2:
check("user_version=0 的旧库被平滑接管", reg2.stats()["total"] == 0)
def test_concurrent_open():
"""两个实例同时打开同一库(多个 GUI 窗口 / CLI 并行)不应互相报错。"""
tmp = Path(tempfile.mkdtemp())
path = tmp / "r.db"
a = Registry(path)
b = Registry(path)
try:
a.save(make_doc(lic_id="L-C1", customer="并发A"), public_key_fp=FP)
b.save(make_doc(lic_id="L-C2", customer="并发B"), public_key_fp=FP)
check("两实例各自写入后互相可见",
{r.lic_id for r in a.query()} == {"L-C1", "L-C2"},
str([r.lic_id for r in a.query()]))
check("B 能看到 A 的记录", b.get("L-C1") is not None)
finally:
a.close()
b.close()
def test_delete():
tmp = Path(tempfile.mkdtemp())
with Registry(tmp / "r.db") as reg:
reg.save(make_doc(lic_id="L-DEL"), public_key_fp=FP)
check("删除成功", reg.delete("L-DEL") is True)
check("删除后查不到", reg.get("L-DEL") is None)
check("删除不存在的返回 False", reg.delete("L-DEL") is False)
def main():
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for fn in tests:
print(f"\n--- {fn.__name__} ---")
fn()
print("\n" + "=" * 56)
if failures:
print(f"❌ {len(failures)} 项未通过: {failures}")
return 1
print("✅ 全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())
+358
View File
@@ -0,0 +1,358 @@
"""chongming-license 库层测试:指纹、机器码、验签、容错匹配、时钟防线。
运行方式:`python test/test_license_verify.py`,或 `uv run pytest test/test_license_verify.py`。
"""
import base64
import json
import sys
import tempfile
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from cryptography.hazmat.primitives import serialization # noqa: E402
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: E402
from chongming_license import ( # noqa: E402
REASON_CLOCK_ROLLBACK,
REASON_EXPIRED,
REASON_MACHINE_MISMATCH,
REASON_NOT_YET_VALID,
REASON_OK,
REASON_PLUGIN_NOT_LICENSED,
REASON_SIGNATURE_INVALID,
ClockGuard,
LicenseManager,
MemoryClockStore,
RequestCodeError,
decode_request_code,
encode_request_code,
machine_code,
match_fingerprints,
normalize,
read_license,
sign_payload,
)
failures: list[str] = []
def check(name, cond, detail=""):
status = "PASS" if cond else "FAIL"
print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
if not cond:
failures.append(name)
# ---------------------------------------------------------------------------
# 夹具
# ---------------------------------------------------------------------------
# 模拟真实盒子的指纹:4 个可信来源 + 1 个 weak(同镜像刷机会重复的 machine-id)
HW = {
"board_serial": "sn123456789",
"emmc_cid": "1a2b3c4d",
"cpu_serial": "0000deadbeef",
"mac": "aabbccddeeff",
"machine_id": "sharedbyallunits",
}
def _keypair():
priv = Ed25519PrivateKey.generate()
pub = base64.b64encode(
priv.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
).decode("ascii")
return pub, priv
def _write_license(tmp: Path, priv, sources=None, plugins=None, **over) -> str:
src = HW if sources is None else sources
payload = {
"v": 1,
"lic_id": "L-TEST",
"customer": "测试客户",
# sources 为空表示"只签机器码"的授权(无逐来源容错),此时机器码必须由调用方给定
"machine_code": over.pop("machine_code") if not src else machine_code(src),
"sources": src,
"plugins": plugins if plugins is not None else ["aibox_sophon_adapter"],
"not_before": None,
"expires_at": (date.today() + timedelta(days=365)).isoformat(),
"issued_at": "2026-09-20T00:00:00Z",
"note": "",
}
payload.update(over)
doc = {"payload": payload, "alg": "ed25519", "kid": "test",
"sig": sign_payload(payload, priv)}
path = tmp / "license.lic"
path.write_text(json.dumps(doc), encoding="utf-8")
return str(path)
# ---------------------------------------------------------------------------
# 指纹与机器码
# ---------------------------------------------------------------------------
def test_normalize_rejects_placeholders():
"""占位符/全 0/全 f/过短一律视为无效。"""
for bad in ["", " ", "unknown", "None", "0000000000000000", "ffffffffffff",
"ab", "00000000-0000-0000-0000-000000000000"]:
check(f"normalize 拒绝 {bad!r}", normalize(bad) == "", f"got {normalize(bad)!r}")
check("normalize 保留合法值", normalize("SN-1234-ABCD") == "sn1234abcd",
normalize("SN-1234-ABCD"))
check("normalize 分隔符差异归一", normalize("AA:BB:CC:DD:EE:FF") == normalize("aabbccddeeff"))
def test_machine_code_deterministic_and_weak_excluded():
"""机器码确定,且不受 weak 来源影响。"""
a = machine_code(HW)
b = machine_code(dict(reversed(list(HW.items()))))
check("机器码与来源顺序无关", a == b)
check("机器码分组格式", len(a.split("-")) == 6 and len(a.replace("-", "")) == 24, a)
other = dict(HW, machine_id="anothervalue")
check("weak 来源变化不影响机器码", machine_code(other) == a)
strong_changed = dict(HW, mac="112233445566")
check("可信来源变化会改变机器码", machine_code(strong_changed) != a)
# ---------------------------------------------------------------------------
# 容错匹配
# ---------------------------------------------------------------------------
def test_tolerance_single_component_swap():
"""换一块网卡 / 换一颗 eMMC 仍通过;整盘拷到别的机器不通过。"""
same = match_fingerprints(HW, dict(HW))
check("同机全命中", same.ok and same.matched == 4 and same.total == 4, same.reason)
nic = match_fingerprints(HW, dict(HW, mac="112233445566"))
check("换网卡仍通过(3/4)", nic.ok, nic.reason)
emmc = match_fingerprints(HW, dict(HW, emmc_cid="ffffffff"))
check("换 eMMC 仍通过(3/4)", emmc.ok, emmc.reason)
two = match_fingerprints(HW, dict(HW, mac="112233445566", emmc_cid="ffffffff"))
check("同时换网卡与 eMMC 不通过(2/4)", not two.ok, two.reason)
copied = match_fingerprints(
HW,
{"board_serial": "x1", "emmc_cid": "x2", "cpu_serial": "x3", "mac": "x4",
"machine_id": "sharedbyallunits"},
)
check("整盘拷贝到另一台机器不通过(weak 来源不得计票)",
not copied.ok and copied.matched == 0, copied.reason)
check("判定未降级", not copied.degraded, str(copied.degraded))
def test_tolerance_degraded_when_only_weak():
"""仅有 weak 来源时降级判定,并显式标记。"""
r = match_fingerprints({"machine_id": "shared"}, {"machine_id": "shared"})
check("仅 weak 来源时降级通过", r.ok and r.degraded, r.reason)
# ---------------------------------------------------------------------------
# 验签与判定
# ---------------------------------------------------------------------------
def test_sign_verify_roundtrip_and_tamper():
tmp = Path(tempfile.mkdtemp())
pub, priv = _keypair()
path = _write_license(tmp, priv)
st = read_license(path, pub, sources=HW)
check("正常授权校验通过", st.ok and st.code == REASON_OK, st.message)
check("解析出授权插件", st.licensed_plugins == {"aibox_sophon_adapter"},
str(st.licensed_plugins))
# 篡改白名单(典型的"自己加一个插件")→ 签名失效
doc = json.loads(Path(path).read_text())
doc["payload"]["plugins"] = ["aibox_sophon_adapter", "doubao"]
bad = tmp / "tampered.lic"
bad.write_text(json.dumps(doc))
st2 = read_license(str(bad), pub, sources=HW)
check("篡改白名单被识破", not st2.ok and st2.code == REASON_SIGNATURE_INVALID,
st2.message)
# 换公钥(非本厂商签发)
other_pub, _ = _keypair()
st3 = read_license(path, other_pub, sources=HW)
check("非本厂商公钥验签失败",
not st3.ok and st3.code == REASON_SIGNATURE_INVALID, st3.message)
# 字段重排序不影响验签(签名覆盖规范化 JSON)
doc = json.loads(Path(path).read_text())
doc["payload"] = dict(reversed(list(doc["payload"].items())))
reordered = tmp / "reordered.lic"
reordered.write_text(json.dumps(doc))
st4 = read_license(str(reordered), pub, sources=HW)
check("载荷字段重排序不影响验签", st4.ok, st4.message)
def test_expiry_and_not_before():
tmp = Path(tempfile.mkdtemp())
pub, priv = _keypair()
expired = _write_license(
tmp, priv, expires_at=(date.today() - timedelta(days=1)).isoformat()
)
st = read_license(expired, pub, sources=HW)
check("过期授权被拒", not st.ok and st.code == REASON_EXPIRED, st.message)
# 到期当天仍有效(含首尾当天)
today = _write_license(tmp, priv, expires_at=date.today().isoformat())
st2 = read_license(today, pub, sources=HW)
check("到期当天仍然有效", st2.ok, st2.message)
future = _write_license(
tmp, priv, not_before=(date.today() + timedelta(days=5)).isoformat()
)
st3 = read_license(future, pub, sources=HW)
check("未生效授权被拒", not st3.ok and st3.code == REASON_NOT_YET_VALID, st3.message)
never = _write_license(tmp, priv, expires_at=None)
st4 = read_license(never, pub, sources=HW)
check("无到期日视为永久有效", st4.ok, st4.message)
def test_machine_mismatch_and_plugin_whitelist():
tmp = Path(tempfile.mkdtemp())
pub, priv = _keypair()
path = _write_license(tmp, priv, plugins=["ip_sound_column"])
st = read_license(path, pub, sources=HW)
check("本机授权有效", st.ok, st.message)
mgr = LicenseManager(license_path=path, public_key=pub, min_match=3,
clock_store=MemoryClockStore(), collector=lambda: HW)
check("白名单内插件放行", mgr.check_plugin("ip_sound_column").allowed)
denied = mgr.check_plugin("doubao")
check("白名单外插件拒绝",
not denied.allowed and denied.code == REASON_PLUGIN_NOT_LICENSED, denied.message)
other_machine = dict(HW, mac="999999999999", cpu_serial="888888888888")
st2 = read_license(path, pub, sources=other_machine)
check("机器码不符拒绝",
not st2.ok and st2.code == REASON_MACHINE_MISMATCH, st2.message)
def test_machine_code_only_license_fallback():
"""只签机器码(无逐来源指纹)时退化为精确匹配。"""
tmp = Path(tempfile.mkdtemp())
pub, priv = _keypair()
path = _write_license(tmp, priv, sources={}, machine_code=machine_code(HW))
st = read_license(path, pub, sources=HW)
check("仅机器码的授权:本机通过", st.ok, st.message)
st2 = read_license(path, pub, sources=dict(HW, mac="000000000001"))
check("仅机器码的授权:异机拒绝", not st2.ok and st2.code == REASON_MACHINE_MISMATCH,
st2.message)
# ---------------------------------------------------------------------------
# 申请码
# ---------------------------------------------------------------------------
def test_request_code_roundtrip():
code = encode_request_code(HW)
check("申请码前缀", code.startswith("CM1-"), code[:12])
data = decode_request_code(code)
check("申请码解析出全部来源", data["sources"] == HW, str(data["sources"]))
check("申请码含机器码", data["machine_code"] == machine_code(HW))
wrapped = code[:30] + "\n " + code[30:] + " "
check("申请码容忍折行与空白", decode_request_code(wrapped)["sources"] == HW)
try:
decode_request_code(code[:-1] + ("0" if code[-1] != "0" else "1"))
check("申请码校验和可检出抄写错误", False, "未抛异常")
except RequestCodeError as exc:
check("申请码校验和可检出抄写错误", True, str(exc))
try:
decode_request_code("not-a-code")
check("非法申请码被拒", False, "未抛异常")
except RequestCodeError:
check("非法申请码被拒", True)
# ---------------------------------------------------------------------------
# 时钟防线
# ---------------------------------------------------------------------------
def test_clock_guard():
store = MemoryClockStore()
guard = ClockGuard(store, tolerance_seconds=3600)
now = datetime(2026, 9, 20, 12, 0, tzinfo=timezone.utc)
check("首次检查通过并建立水位", guard.check(now).ok)
later = now + timedelta(hours=5)
check("时间前进正常", guard.check(later).ok)
# 回退 30 分钟:容差内,放行
slight = later - timedelta(minutes=30)
check("容差内回退放行", guard.check(slight).ok)
# 回退 10 小时:超过容差,拒绝
rollback = later - timedelta(hours=10)
r = guard.check(rollback)
check("明显回拨被检出", not r.ok and r.rollback_seconds > 0, r.reason)
# 水位未被回退覆盖
check("回拨后水位未被拉低", guard.check(later).ok)
def test_clock_guard_reseeds_when_far_ahead():
"""水位远超当前时间(RTC 异常)时重新起算,避免把设备永久卡死。"""
store = MemoryClockStore()
guard = ClockGuard(store, tolerance_seconds=3600, reseed_if_ahead_days=90)
guard.check(datetime(2027, 1, 1, tzinfo=timezone.utc))
r = guard.check(datetime(2026, 9, 20, tzinfo=timezone.utc))
check("大幅领先判为时钟异常而非攻击", r.ok and "异常" in r.reason, r.reason)
def test_manager_blocks_on_clock_rollback():
"""时钟回拨时管理器的判定整体中止。"""
tmp = Path(tempfile.mkdtemp())
pub, priv = _keypair()
path = _write_license(tmp, priv, plugins=["ip_sound_column"])
store = MemoryClockStore()
far_future = datetime.now(timezone.utc) + timedelta(days=2)
ClockGuard(store, tolerance_seconds=3600).check(far_future)
mgr = LicenseManager(license_path=path, public_key=pub, clock_store=store,
collector=lambda: HW)
st = mgr.reload()
check("时钟回拨时授权判定中止",
not st.ok and st.code == REASON_CLOCK_ROLLBACK, st.message)
check("时钟回拨时插件被拒", not mgr.check_plugin("ip_sound_column").allowed)
def test_manager_disabled_mode_reports_reality():
"""enforce=False(开发/测试)时放行,但状态仍如实反映真实校验结果。"""
tmp = Path(tempfile.mkdtemp())
pub, priv = _keypair()
path = _write_license(tmp, priv, plugins=[])
mgr = LicenseManager(license_path=path, public_key=pub, enforce=False,
collector=lambda: HW)
check("未启用时插件放行", mgr.check_plugin("doubao").allowed)
check("未启用时仍如实报告授权有效", mgr.status.ok, mgr.status.message)
def main():
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for fn in tests:
print(f"\n--- {fn.__name__} ---")
fn()
print("\n" + "=" * 56)
if failures:
print(f"❌ {len(failures)} 项未通过: {failures}")
return 1
print("✅ 全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())
Generated
+29 -10
View File
@@ -461,17 +461,18 @@ wheels = [
[[package]]
name = "chongming-cli"
version = "0.3.1"
version = "0.3.2"
source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" }
dependencies = [
{ name = "chongming-config" },
{ name = "chongming-license" },
{ name = "module-bank" },
{ name = "sqlite-vfs" },
{ name = "tomli" },
]
sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.3.1/chongming_cli-0.3.1.tar.gz", hash = "sha256:b5925b9b22ffe374720a48d2614a3a06ace020cba53a20e2b24d53c0e1d2305e" }
sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.3.2/chongming_cli-0.3.2.tar.gz", hash = "sha256:af02210a9fa124eda0533dcb7c833c985e629fc520a70c6fffeb87ee461a1659" }
wheels = [
{ url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.3.1/chongming_cli-0.3.1-py3-none-any.whl", hash = "sha256:189c9e43d0c88817844f689e32686c2f4b66f4e25e89ab23c1adb135d7a3f4e2" },
{ url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.3.2/chongming_cli-0.3.2-py3-none-any.whl", hash = "sha256:57a286c33f364082aa43a71500ec0c918111327053def1d75d36bb3b3acc3eb6" },
]
[[package]]
@@ -513,6 +514,20 @@ wheels = [
{ url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-jwt/0.1.0/chongming_jwt-0.1.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:d63cc24550b7084e55d4dd0cb9f99cbd0a9d77da39ad0685698fd484e4376e72" },
]
[[package]]
name = "chongming-license"
version = "0.1.0"
source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" }
dependencies = [
{ name = "cryptography" },
{ name = "orjson" },
{ name = "pydantic" },
]
sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-license/0.1.0/chongming_license-0.1.0.tar.gz", hash = "sha256:30f3752a53ea76465c47ad324cbe1e4cfc656df175a9864f6d217d7b983d21c9" }
wheels = [
{ url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-license/0.1.0/chongming_license-0.1.0-py3-none-any.whl", hash = "sha256:6369da005874c83a279d35cbaffcd606c494b49514e4896b665e6f19ba2aa36a" },
]
[[package]]
name = "chongming-mqtt"
version = "0.1.0"
@@ -587,6 +602,7 @@ dependencies = [
{ name = "chongming-auth" },
{ name = "chongming-cache" },
{ name = "chongming-jwt" },
{ name = "chongming-license" },
{ name = "chongming-mqtt" },
{ name = "chongming-password" },
{ name = "chongming-request" },
@@ -624,6 +640,7 @@ requires-dist = [
{ name = "chongming-auth" },
{ name = "chongming-cache" },
{ name = "chongming-jwt" },
{ name = "chongming-license" },
{ name = "chongming-mqtt" },
{ name = "chongming-password" },
{ name = "chongming-request" },
@@ -1119,14 +1136,15 @@ wheels = [
[[package]]
name = "module-bank"
version = "0.3.2"
version = "0.4.2"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyarmor" },
{ name = "sqlite-vfs" },
]
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8a/d3/0a7a8297ec3041e0352f313e020276a04c59bc9200138094df2a6d374f51/module_bank-0.3.2-py3-none-any.whl", hash = "sha256:524faaa86258ebbdc07df493a041566adfbdf57461487a63bf7161e6aa79a817", size = 19051, upload-time = "2026-05-04T16:21:19.827Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bc/a6/436bc5ffd88b5bf00afff5632a3f02a1aa679487fff4e0d4c581af973c6b/module_bank-0.4.2-py3-none-any.whl", hash = "sha256:a89fdd7a5c1b8b2b6a073a4208d42e17cbee7cbf60971436b19d236b97b11b88", size = 44987, upload-time = "2026-09-20T09:32:06.539Z" },
]
[[package]]
@@ -1137,7 +1155,7 @@ sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c7/ce/38
[[package]]
name = "openai"
version = "3.15.0"
version = "3.16.2"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
dependencies = [
{ name = "anyio" },
@@ -1147,9 +1165,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/82/6b/f086332d9fdefcc3853f2457209fcf221985d01d32b5021ab8096be7d506/openai-3.15.0.tar.gz", hash = "sha256:47bc15f8b9db62ba9ff7fa32164c72f4fd6333b22d890251292aac1c6bfe998f", size = 1664554, upload-time = "2026-09-18T00:53:05.192Z" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e2/c6/770171ef28854d8092ee2c3df68ef97d127362b28771e6b423c948894e07/openai-3.16.2.tar.gz", hash = "sha256:e129455fc6a744276cda63a9e2f0759207bc0cf24afc4cf356bf5029e631e955", size = 1696266, upload-time = "2026-09-18T21:27:23.586Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fb/92/fc65194e6e92cf0202a2e5fff75e2fef62e7dbb7a6cf2b48c1239dfedb50/openai-3.15.0-py3-none-any.whl", hash = "sha256:95d6b2caf3823c7e0fc08911317b7f31b9f9ccf93b414e7a5c8d5b226af115f6", size = 2018693, upload-time = "2026-09-18T00:53:02.507Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/17/72/2b1fe3e303db76ab2ba0c2de2b57822869bcb588db82334d4ff3aea8acf2/openai-3.16.2-py3-none-any.whl", hash = "sha256:660a7f8307e6605342ae84ff4425412e7cd19e5e9b298f975502df2a1a9a5d5f", size = 2053568, upload-time = "2026-09-18T21:27:20.885Z" },
]
[[package]]
@@ -1740,10 +1758,11 @@ wheels = [
[[package]]
name = "sqlite-vfs"
version = "0.3.0"
version = "0.4.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1a/b2/d4d1dcdbf13d9a9884b30d5cab90d518d86e5f1ec3531acddfbe8ebcdd1a/sqlite_vfs-0.4.0.tar.gz", hash = "sha256:970cc30d0cd16b3bc9f02b08af37eb8fcf89523b6c17740a86cd9cc260d544a4", size = 740965, upload-time = "2026-09-20T08:13:35.884Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/13/28/29f6e4ec636c8ea5a91912079044c415cf59828d8185006ed1ca0104b9ba/sqlite_vfs-0.3.0-py3-none-any.whl", hash = "sha256:21db55f73867fee539aa8ae9e58e4dd06173d7b856d5d4a29e86d9d60364cffb", size = 82968, upload-time = "2026-09-16T01:53:57.007Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/37/9f/5b32146512efc290df1a7c654f603317346d17b04684ea55dff19affe18d/sqlite_vfs-0.4.0-py3-none-any.whl", hash = "sha256:d2584e8d1cf5908f30aea6db6b95eaa01c62be426bde41e5ff7d226d40128a31", size = 94794, upload-time = "2026-09-20T08:13:34.216Z" },
]
[[package]]