Compare commits
2
Commits
4cdb9c7049
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac8b563c7d | ||
|
|
774f44f830 |
@@ -331,6 +331,12 @@ exe 内的依赖来自三处,新增第三方包时对号入座:
|
||||
`--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
|
||||
|
||||
+12
-2
@@ -102,8 +102,10 @@ config_path = "./config/workers/config.toml"
|
||||
|
||||
[pyinstaller_build_import]
|
||||
# 打包收集清单(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 插件、
|
||||
# sqlite_vfs.contrib、chongming_worker 子模块等)
|
||||
# - public 骨架自身(server / utils)与服务器可选实现(uvloop 等)
|
||||
@@ -122,6 +124,14 @@ base = [
|
||||
"chongming_request",
|
||||
"chongming_auth",
|
||||
"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", # 模块化打包工具
|
||||
"sqlite_vfs",
|
||||
"sqlite_vfs.core",
|
||||
|
||||
@@ -28,6 +28,14 @@ launch_worker_count = "cache"
|
||||
algorithm = "HS256"
|
||||
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]
|
||||
debug = false
|
||||
env = "production"
|
||||
|
||||
+123
-5
@@ -8,7 +8,10 @@ build 命令开关(--encrypt / --license)冒烟测试
|
||||
2. 默认(不加密):.mbank 存明文字节码(encrypted=false)、不写 .key(并清掉旧
|
||||
的)、无 packer_key 记录,且**不需要密钥即可导入**;加 --encrypt 那条路仍然
|
||||
加密、仍然写 .key,且用 .key 里的密钥能正常导入
|
||||
3. 默认(不授权):prepare 不要求/不注入授权公钥;加 --license 时缺公钥构建失败
|
||||
3. 授权构建:prepare 把"是否为授权版"烘焙进 chongming/_build_flags.py(编译进
|
||||
产物、部署侧不可改)—— 不加 --license = 非授权版(不校验、插件照常加载),
|
||||
加 --license = 授权版(注入并内置公钥、强制校验);--license 缺公钥默认构建
|
||||
失败,显式放行时降级为非授权版(而不是拒绝所有插件的砖头包);非法公钥报错
|
||||
|
||||
运行: uv run python test/test_build_flags.py
|
||||
"""
|
||||
@@ -17,6 +20,7 @@ import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
SRC = Path(__file__).parent
|
||||
@@ -155,10 +159,59 @@ mod, err = import_from_bank(encrypted_bank, "demo", key=encrypted_bank.with_suff
|
||||
check("加密包用 .key 里的密钥可导入", mod is not None, str(err))
|
||||
|
||||
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.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 "",
|
||||
)
|
||||
|
||||
|
||||
# ========== 3. 授权公钥注入 / 默认不注入 ==========
|
||||
def make_project(root: Path) -> Path:
|
||||
"""造一个最小构建现场:public/{main.py,server.py,config.toml}"""
|
||||
(root / "public" / "utils").mkdir(parents=True)
|
||||
@@ -173,30 +226,90 @@ def make_project(root: Path) -> Path:
|
||||
return root
|
||||
|
||||
|
||||
def valid_pubkey() -> str:
|
||||
"""一把合法的 base64 裸 32 字节 Ed25519 公钥(构建期会校验格式)。"""
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
return base64.b64encode(
|
||||
priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
).decode("ascii")
|
||||
|
||||
|
||||
def flags_path() -> Path:
|
||||
"""烘焙标记的落盘位置(网关包内;CLI 用同一方式定位)。"""
|
||||
spec = importlib.util.find_spec("chongming")
|
||||
assert spec and spec.submodule_search_locations, "构建 venv 未安装 chongming-gateway"
|
||||
return Path(next(iter(spec.submodule_search_locations))) / "_build_flags.py"
|
||||
|
||||
|
||||
def read_flags() -> str:
|
||||
return flags_path().read_text(encoding="utf-8")
|
||||
|
||||
|
||||
env_keys = (b.LICENSE_PUBKEY_ENV, b.LICENSE_PUBKEY_FILE_ENV, b.ALLOW_NO_KEY_ENV)
|
||||
saved_env = {k: os.environ.pop(k, None) for k in env_keys} # 环境变量不该影响测试结论
|
||||
|
||||
cwd = Path.cwd()
|
||||
proj = make_project(tmp / "proj_default")
|
||||
flags_file = flags_path()
|
||||
flags_backup = flags_file.read_text(encoding="utf-8") if flags_file.exists() else None
|
||||
os.chdir(proj)
|
||||
try:
|
||||
# 默认(不授权):产物烘焙"非授权版"——不校验授权、插件照常加载(不是砖头包)
|
||||
msg = b._stage_prepare_build_dir(None, "pyinstaller", b.BuildOptions())
|
||||
conf = (proj / "build" / "config.toml").read_text(encoding="utf-8")
|
||||
check("默认(不授权)的 prepare 不报错", msg == "资源准备完成", msg)
|
||||
check("默认不注入公钥(模板 public_key 保持空)", 'public_key = ""' in conf)
|
||||
check("JWT 密钥仍会注入", "your-secret-key-change-in-production" not in conf)
|
||||
check("默认烘焙非授权版标记(enforce=False)", flags_file.exists() and "LICENSE_ENFORCE = False" in read_flags())
|
||||
|
||||
# --license 缺公钥:默认让构建失败(避免打出名不副实的"授权版")
|
||||
try:
|
||||
b._stage_prepare_build_dir(None, "pyinstaller", b.BuildOptions(license=True))
|
||||
check("--license 缺授权公钥应失败", False)
|
||||
except RuntimeError as exc:
|
||||
check("--license 缺授权公钥应失败", "缺少厂商授权公钥" in str(exc))
|
||||
|
||||
os.environ[b.LICENSE_PUBKEY_ENV] = "test-pubkey-b64"
|
||||
# --license 缺公钥 + 显式放行:烘焙"非授权版"(旧行为是产出拒绝所有插件的砖头包)
|
||||
os.environ[b.ALLOW_NO_KEY_ENV] = "1"
|
||||
try:
|
||||
b._stage_prepare_build_dir(None, "pyinstaller", b.BuildOptions(license=True))
|
||||
check("显式放行时不报错", True)
|
||||
check("显式放行时烘焙非授权版标记(enforce=False)",
|
||||
"LICENSE_ENFORCE = False" in read_flags())
|
||||
finally:
|
||||
os.environ.pop(b.ALLOW_NO_KEY_ENV, None)
|
||||
|
||||
# --license + 公钥:注入产物配置 + 烘焙授权版标记(公钥随代码内置)
|
||||
pubkey = valid_pubkey()
|
||||
os.environ[b.LICENSE_PUBKEY_ENV] = pubkey
|
||||
try:
|
||||
b._stage_prepare_build_dir(None, "pyinstaller", b.BuildOptions(license=True))
|
||||
conf = (proj / "build" / "config.toml").read_text(encoding="utf-8")
|
||||
check("--license + 提供公钥后注入 [license] public_key", 'public_key = "test-pubkey-b64"' in conf)
|
||||
flags_src = read_flags()
|
||||
ns: dict = {}
|
||||
exec(compile(flags_src, str(flags_file), "exec"), ns) # 生成物必须是合法 Python
|
||||
check("--license + 公钥:注入 [license] public_key", f'public_key = "{pubkey}"' in conf)
|
||||
check("--license + 公钥:烘焙授权版标记", ns.get("LICENSE_ENFORCE") is True)
|
||||
check("--license + 公钥:公钥随代码内置", ns.get("LICENSE_PUBLIC_KEY") == pubkey)
|
||||
check("--license + 公钥:记录公钥指纹", bool(ns.get("PUBLIC_KEY_FINGERPRINT")))
|
||||
finally:
|
||||
os.environ.pop(b.LICENSE_PUBKEY_ENV, None)
|
||||
|
||||
# 非法公钥:构建期即报错(否则会打出"谁都验不过"的授权版产物)
|
||||
os.environ[b.LICENSE_PUBKEY_ENV] = "not-a-real-key"
|
||||
try:
|
||||
b._stage_prepare_build_dir(None, "pyinstaller", b.BuildOptions(license=True))
|
||||
check("非法公钥应终止构建", False)
|
||||
except Exception as exc: # noqa: BLE001 - 具体异常类型由公钥库决定
|
||||
check("非法公钥应终止构建", "公钥" in str(exc) or "key" in str(exc).lower(), str(exc)[:60])
|
||||
finally:
|
||||
os.environ.pop(b.LICENSE_PUBKEY_ENV, None)
|
||||
finally:
|
||||
@@ -204,6 +317,11 @@ finally:
|
||||
for k, v in saved_env.items():
|
||||
if v is not None:
|
||||
os.environ[k] = v
|
||||
# 清理写进构建 venv 的烘焙标记(测试不应留下副作用)
|
||||
if flags_backup is None:
|
||||
flags_file.unlink(missing_ok=True)
|
||||
else:
|
||||
flags_file.write_text(flags_backup, encoding="utf-8")
|
||||
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user