三处收口,未授权插件不加载、不暴露端点、不可管理:
- 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 项:门禁/契约存活/描述符拦截/管理接口/上传安全/烘焙三态,自造夹具直接运行)。
209 lines
7.9 KiB
Python
209 lines
7.9 KiB
Python
"""机器码授权管理端点(查看授权状态 / 展示机器码 / 导入 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 "无新增插件需要加载,无需重启。"
|
||
),
|
||
}
|