fix: --encrypt/--license 构建链修复(网关收集项、[license] 注入点、契约测试)

- config.toml:--encrypt 会先 PyArmor 混淆 main.py / server.py,PyInstaller 静态
  分析跟不到里面的 `from chongming.app import app`,网关包整个漏收(打包"成功"、
  产物启动即报 No module named 'chongming')—— 收集清单显式列出 chongming /
  chongming.app 等入口包,注释也从"静态分析可跟到"改准;
- public/config.toml:补 [license] 段(public_key 留空),--license 的注入点,
  缺了会在 prepare 阶段报"未找到 [license] 段的 public_key";
- README:补 --license 的公钥前置条件(keygen → license_public.key 或环境变量);
- test_build_flags.py:4 条契约守卫 —— 真实模板/骨架可注入 [license]、收集清单含
  网关入口包、加密包密钥写进包内且按运行端口径(get_key())可导入。此前用例都
  自造 config.toml,这两类模板漂移测不出来。

实测(device-hub):修复前 --encrypt --license 产物启动报错;修复后网关启动、
授权校验通过、插件从加密 mbank 正常加载、HTTP 正常返回。
This commit is contained in:
2026-10-14 15:29:29 +08:00
parent 774f44f830
commit ac8b563c7d
4 changed files with 77 additions and 3 deletions
+6
View File
@@ -331,6 +331,12 @@ exe 内的依赖来自三处,新增第三方包时对号入座:
`--encrypt` / `--license` 默认**都关**(不加参数即内部调试产物:明文模块、不校验授权); `--encrypt` / `--license` 默认**都关**(不加参数即内部调试产物:明文模块、不校验授权);
对外分发必须同时加这两个开关。 对外分发必须同时加这两个开关。
`--license` 需要厂商授权公钥(未提供直接构建失败,避免打出名不副实的"授权版")。首次:
`uv run chongming-license keygen -o license_private.pem` 生成密钥对(一次性;私钥离线保管,
绝不进仓库/产物),把输出的公钥写入根目录 `license_public.key`,或用
`CHONGMING_LICENSE_PUBKEY=<base64 公钥>` / `CHONGMING_LICENSE_PUBKEY_FILE=<文件路径>` 指定。
构建会把公钥注入产物的 `[license] public_key` 并烘焙进代码(现场改配置/环境变量关不掉强制校验)。
### 进程插件打包与容器部署 ### 进程插件打包与容器部署
```bash ```bash
+12 -2
View File
@@ -102,8 +102,10 @@ config_path = "./config/workers/config.toml"
[pyinstaller_build_import] [pyinstaller_build_import]
# 打包收集清单(PyInstaller --hidden-import / Nuitka --include-module)。 # 打包收集清单(PyInstaller --hidden-import / Nuitka --include-module)。
# 网关本体依赖随 chongming-gateway 包发布,打包时能沿 `from chongming.app import app` # 网关本体(chongming-gateway 包)**未混淆时**能沿 `from chongming.app import app`
# 静态跟到;本清单保留两类: # 静态跟到;但 --encrypt 会先 PyArmor 混淆 main.py / server.py,此时静态分析跟不到,
# 网关入口包必须显式列出(见 base 前几项)。本清单保留三类:
# - 网关入口包(chongming / chongming.app 与骨架直接引用的子模块)
# - 动态导入、静态分析看不到的(granian 内部子模块、amqtt 插件、 # - 动态导入、静态分析看不到的(granian 内部子模块、amqtt 插件、
# sqlite_vfs.contrib、chongming_worker 子模块等) # sqlite_vfs.contrib、chongming_worker 子模块等)
# - public 骨架自身(server / utils)与服务器可选实现(uvloop 等) # - public 骨架自身(server / utils)与服务器可选实现(uvloop 等)
@@ -122,6 +124,14 @@ base = [
"chongming_request", "chongming_request",
"chongming_auth", "chongming_auth",
"server", "server",
# 网关入口包:--encrypt 时 main.py / server.py 先被 PyArmor 混淆,里面的
# `from chongming.app import app` 对 PyInstaller 静态分析不可见 —— 漏了会在
# 打包期"成功"、产物启动即报
# "无法导入网关(chongming.app): No module named 'chongming'"
"chongming",
"chongming.app",
"chongming.app.core.logger",
"chongming.app.core.parent_death",
"module_bank", # 模块化打包工具 "module_bank", # 模块化打包工具
"sqlite_vfs", "sqlite_vfs",
"sqlite_vfs.core", "sqlite_vfs.core",
+8
View File
@@ -28,6 +28,14 @@ launch_worker_count = "cache"
algorithm = "HS256" algorithm = "HS256"
secret_key = "your-secret-key-change-in-production" secret_key = "your-secret-key-change-in-production"
[license]
# 机器码授权(厂商 Ed25519 签发):`uv run build --license` 在打包前把厂商公钥注入
# 这里,并把"本产物是否为授权版"烘焙进产物代码(编译后部署侧改不了)。
# public_key 留空 = 未接入授权:开发态不校验;未加 --license 的产物也不校验
# (插件照常加载,不是砖头包)。可选键:path(授权文件,默认 ./license.lic)、
# min_match(指纹容错阈值)、enabled(仅开发态生效;打包产物以构建期烘焙为准)。
public_key = ""
[production] [production]
debug = false debug = false
env = "production" env = "production"
+51 -1
View File
@@ -20,6 +20,7 @@ import os
import shutil import shutil
import sys import sys
import tempfile import tempfile
import tomllib
from pathlib import Path from pathlib import Path
SRC = Path(__file__).parent SRC = Path(__file__).parent
@@ -158,10 +159,59 @@ mod, err = import_from_bank(encrypted_bank, "demo", key=encrypted_bank.with_suff
check("加密包用 .key 里的密钥可导入", mod is not None, str(err)) check("加密包用 .key 里的密钥可导入", mod is not None, str(err))
mod, err = import_from_bank(encrypted_bank, "demo", key=None) mod, err = import_from_bank(encrypted_bank, "demo", key=None)
check("加密包缺密钥无法导入(说明 .key 必须随包提供给加载端)", mod is None and isinstance(err, ImportError), str(err)) check("加密包不主动取密钥无法导入(install_importer 不会自己读包内密钥)",
mod is None and isinstance(err, ImportError), str(err))
# 运行端取密钥的口径:public/utils/launch.py 的 init_module_bank 用 packer.get_key()
# 从**包内**取(它不读旁挂 .key 文件)。打包时没写进包内,加密产物启动就是
# "模块 'plugins' 的字节码是加密的,但没有提供解密密钥" —— 插件全加载失败、
# 契约校验不过,网关直接起不来(实测踩过)
embedded = b.PythonToSQLite(str(encrypted_bank)).get_key()
check("加密包把密钥写进包内(运行端 get_key() 取得到)", embedded == key, f"embedded={embedded!r}")
mod, err = import_from_bank(encrypted_bank, "demo", key=embedded)
check("加密包按运行端口径(get_key() 取出的密钥)可导入", mod is not None, str(err))
# ========== 3. 授权构建:烘焙标记 / 公钥注入 ========== # ========== 3. 授权构建:烘焙标记 / 公钥注入 ==========
# 3.0 先验真实模板/骨架的构建契约:下面的用例(含 make_project)都自造 config.toml,
# 所以"模板漏了 [license] 段 / 漏了网关收集项"这类漂移测不出来 —— 而两种漏法都不好在
# 构建期发现(前者首报是"缺少厂商授权公钥",要补完公钥才暴露"未找到 [license] 段的
# public_key";后者更隐蔽:打包"成功",产物启动才报 No module named 'chongming')。
# 两处契约都在模板(生成新项目用)与根骨架(本仓打包用)的配置里,一起验。
GATEWAY_ENTRY_MODULES = ("chongming", "chongming.app") # --encrypt 时静态分析看不到
for label, proj_root in (
("模板", SRC.parent / "utils" / "template"),
("根骨架", SRC.parent),
):
try:
injected = b._set_license_pubkey(
(proj_root / "public" / "config.toml").read_text(encoding="utf-8"), "TESTKEY"
)
check(
f"{label} public/config.toml 可注入 [license] public_key",
'public_key = "TESTKEY"' in injected,
)
except Exception as exc: # noqa: BLE001 - 缺段/缺键都算契约破坏
check(f"{label} public/config.toml 可注入 [license] public_key", False, str(exc)[:60])
# 收集清单要给 --encrypt 兜底:PyArmor 混淆 main.py/server.py 后,打包器跟不到
# 里面的 `from chongming.app import app`,网关入口包只能靠这里显式声明
with open(proj_root / "config.toml", "rb") as f:
pack_list = tomllib.load(f)["pyinstaller_build_import"]
listed = (
set(pack_list.get("base", []))
| set(pack_list.get("linux", []))
| set(pack_list.get("windows", []))
)
missing = [m for m in GATEWAY_ENTRY_MODULES if m not in listed]
check(
f"{label} config.toml 收集清单含网关入口包",
not missing,
f"缺 {missing}" if missing else "",
)
def make_project(root: Path) -> Path: def make_project(root: Path) -> Path:
"""造一个最小构建现场:public/{main.py,server.py,config.toml}""" """造一个最小构建现场:public/{main.py,server.py,config.toml}"""
(root / "public" / "utils").mkdir(parents=True) (root / "public" / "utils").mkdir(parents=True)