Files
chongming-gateway/src/chongming/app/routes/licensing.py
T
chakcy 93fed12d14 feat: 集成 chongming-license 机器码授权门禁(打包可选、授权版不可关)
三处收口,未授权插件不加载、不暴露端点、不可管理:
- core/licensing:授权管理器单例 + 构建期烘焙决策(chongming/_build_flags.py,
  随代码编译、部署侧不可改);开发/测试默认放行,CHONGMING_LICENSE_MODE 可临时开
- lifespan_setup:_scan_plugins(盘扫)与 _discover_plugins(授权收口)分离,
  未授权插件连同 TOML 路由/MQTT 主题/副本数一起从启动流程消失(契约校验因此
  不会硬失败),启动打印授权横幅
- route_registry.set_denied_types:未授权类型经 Zenoh 推来的描述符一律忽略
  (否则把 load_mode 改成 external 即可绕过)
- worker_admin:扩缩容/重启/读改配置对未授权插件一律 403,实例盘点附 licensed 标注
- routes/licensing:/license/{info,reload,file}(仅超管),导入先验签后原子落盘,
  坏文件不覆盖现有授权;导入不即时加载插件,如实告知需重启

打包可选:不加 --license 出"非授权版"(不校验、插件照常加载);授权版强制校验
且公钥随代码内置(现场换 config.toml 公钥自签也无效)。未烘焙的旧打包产物沿用
"打包即强制"的旧语义;烘焙标记只在产物内生效,不影响源码运行。

补依赖 greenlet(chongming-auth→database 的 sqlalchemy asyncio import 期即要求)
与 python-multipart(上传端点注册路由即要求);新增 test/test_license_gate.py
(52 项:门禁/契约存活/描述符拦截/管理接口/上传安全/烘焙三态,自造夹具直接运行)。
2026-10-14 10:57:09 +08:00

209 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""机器码授权管理端点(查看授权状态 / 展示机器码 / 导入 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 "无新增插件需要加载,无需重启。"
),
}