From 93fed12d14d05579ef015735e6c1076a9c2b7070 Mon Sep 17 00:00:00 2001 From: chakcy <947105045@qq.com> Date: Wed, 14 Oct 2026 10:57:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=20chongming-license?= =?UTF-8?q?=20=E6=9C=BA=E5=99=A8=E7=A0=81=E6=8E=88=E6=9D=83=E9=97=A8?= =?UTF-8?q?=E7=A6=81=EF=BC=88=E6=89=93=E5=8C=85=E5=8F=AF=E9=80=89=E3=80=81?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E7=89=88=E4=B8=8D=E5=8F=AF=E5=85=B3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处收口,未授权插件不加载、不暴露端点、不可管理: - 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 项:门禁/契约存活/描述符拦截/管理接口/上传安全/烘焙三态,自造夹具直接运行)。 --- .gitignore | 3 + README.md | 74 ++- pyproject.toml | 13 +- src/chongming/app/__init__.py | 6 +- src/chongming/app/core/licensing.py | 264 ++++++++++ src/chongming/app/core/lifespan_setup.py | 83 ++- src/chongming/app/core/route_registry.py | 28 + src/chongming/app/routes/__init__.py | 3 +- src/chongming/app/routes/licensing.py | 208 ++++++++ src/chongming/app/routes/worker_admin.py | 52 +- test/test_license_gate.py | 625 +++++++++++++++++++++++ uv.lock | 112 ++++ 12 files changed, 1456 insertions(+), 15 deletions(-) create mode 100644 src/chongming/app/core/licensing.py create mode 100644 src/chongming/app/routes/licensing.py create mode 100644 test/test_license_gate.py diff --git a/.gitignore b/.gitignore index c8f0442..0775a08 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ venv/ .installed.cfg *.egg +# 授权版标记:打包脚本构建期生成(可编辑安装时落在 src/chongming/ 下),非源码 +src/chongming/_build_flags.py + # Installer logs pip-log.txt pip-delete-this-directory.txt diff --git a/README.md b/README.md index fe89a03..527064d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ chongming 网关本体:基于 **FastAPI + Zenoh** 的配置驱动微服务网关。 把宿主项目里的插件(inline 进程内 / external 独立进程)发布的路由统一挂载为 HTTP 端点, -并提供 Worker 服务发现、负载均衡、超时重试、实例定向、MQTT Bridge 与内置登录鉴权。 +并提供 Worker 服务发现、负载均衡、超时重试、实例定向、MQTT Bridge、内置登录鉴权 +与机器码授权(chongming-license)。 网关只提供**代码**:配置、插件与打包骨架由宿主项目提供(见「运行时约定」)。 @@ -42,12 +43,70 @@ uvicorn chongming.app:app --host 0.0.0.0 --port 8000 ## 运行时约定 -- **配置**:cwd 下 `config.toml`(环境、服务器、安全、MQTT、缓存等)与 +- **配置**:cwd 下 `config.toml`(环境、服务器、安全、MQTT、缓存、授权等)与 `config/workers/**`(插件注册与路由定义),运行期读取,随宿主项目部署。 - **插件**:网关按 `plugins.` 约定导入插件包,来源不限——`.mbank` 模块银行、 源码目录(开发态 `src/plugins`)或已安装包均可。 - **工作目录**:日志、上传、数据等相对路径均以 cwd 为基准。 +## 机器码授权(chongming-license) + +厂商用**私钥**离线签发 `license.lic`(绑定机器指纹 + 插件白名单 + 有效期), +网关内置**公钥**验签,全程无需联网。未授权的插件不加载、不注册路由 +(external 插件经 Zenoh 推来的描述符同样被忽略)、管理接口一律 403。 +是否启用校验见下方「打包」一节:非授权版产物完全不校验,授权版产物强制校验。 + +宿主 `config.toml` 的 `[license]` 段(缺省视为未配置,不启用校验): + +```toml +[license] +# 是否执行拒绝。**只在非打包态生效**(uv run serve / 测试)——打包产物的 +# 开关由构建期烘焙决定(部署目录的 config.toml 明文可写,以它为准等于放行 +# 现场改一行就绕过授权) +enabled = false +path = "license.lic" # 授权文件路径(相对网关工作目录) +public_key = "" # 厂商公钥(base64 裸 32 字节或 PEM);由构建脚本注入, + # 授权版还会把公钥烘焙进产物代码,此处仅作镜像/兜底 +min_match = 3 # 指纹命中阈值(N-of-M),容忍单块网卡/eMMC 更换 +``` + +非打包态也可用 `CHONGMING_LICENSE_MODE=on`(或 `off`)临时开启/关闭, +便于在开发机上验证门禁逻辑。 + +### 打包:license 是可选构建项 + +"是否强制校验"由**构建期烘焙**决定,而不是运行时可写的配置 —— 打包脚本在编译前 +生成 `chongming/_build_flags.py`(随代码编译进产物,部署侧不可改): + +| 构建方式 | 产物 | 行为 | +| --- | --- | --- | +| 不加 `--license` | 非授权版 | 不校验授权,插件照常加载(与开发态一致) | +| `--license` + 厂商公钥 | 授权版 | 强制校验;公钥随代码内置,现场换 `config.toml` 里的公钥也无法自签绕过 | +| 旧打包脚本(未烘焙) | 兼容旧语义 | 打包即强制校验 | + +即**不配 license 也能正常出包**(非授权版不是"拒绝所有插件"的砖头包), +授权版则无法被现场改配置/环境变量关掉。宿主打包命令: + +```bash +uv run build --encrypt --license # 授权版产物(读 CHONGMING_LICENSE_PUBKEY / license_public.key) +uv run build --encrypt # 非授权版产物:不校验、插件照常加载 +``` + +烘焙标记只对打包产物生效——源码运行(含可编辑安装)仍只由上面的配置/环境变量 +决定,不会因为上次打了授权版包就把开发态也锁上。 + +授权管理端点(`/api/v1/license/...`,仅超级管理员): + +| 端点 | 用途 | +| --- | --- | +| `GET /license/info` | 机器码、申请码、授权状态、各插件授权情况与硬件来源明细 | +| `POST /license/reload` | 重新读取磁盘上的授权文件(不改插件加载状态) | +| `POST /license/file` | 导入 `license.lic`:先验签/机器码/有效期,通过才原子替换 | + +导入接口**先校验后落盘**:坏文件一律拒绝且不覆盖现有可用授权;通过校验但 +白名单含此前未授权的插件时,需**重启网关**才会加载(响应里的 `restart_required` +如实告知)。申请授权时把 `/license/info` 的 `request_code` 发给厂商即可。 + ## 目录结构 ```text @@ -55,8 +114,11 @@ src/chongming/ ├── __init__.py # 启动入口(serve / granian / gunicorn)与服务器参数转换 └── app/ ├── __init__.py # FastAPI 应用装配(lifespan、中间件、异常处理) - ├── core/ # WorkerManager / RouteGenerator / MQTT Bridge / 日志 / 鉴权 - └── routes/ # 内置管理端点(stats / worker_admin / auth) + ├── core/ # WorkerManager / RouteGenerator / MQTT Bridge / 日志 / 鉴权 / 授权门禁 + └── routes/ # 内置管理端点(stats / worker_admin / auth / license) + +test/ +└── test_license_gate.py # 授权门禁测试(自造夹具,直接 python 运行) ``` ## 开发与发布 @@ -65,6 +127,9 @@ src/chongming/ # 在 chongming-core 仓库内改代码后,让 venv 用本地源码(联调用) uv pip install -e utils/gateway +# 授权门禁测试(自造夹具,可在任意目录直接运行) +python test/test_license_gate.py + # 发版:构建 wheel 并推私有源 uv build # dist/chongming_gateway--py3-none-any.whl uv run twine upload --repository <私有源> dist/* @@ -77,3 +142,4 @@ uv run twine upload --repository <私有源> dist/* - [项目主 README](../../README.md):整体架构、使用与构建部署 - [Worker 管理与多实例运维](../../docs/worker-management.md):管理端点、扩缩容、实例定向 +- [机器码授权指南](../../docs/license.md):密钥管理、签发/激活流程、指纹容错与命令行参考 diff --git a/pyproject.toml b/pyproject.toml index d86e390..7b64451 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["hatchling"] [project] name = "chongming-gateway" -version = "0.1.0" +version = "0.1.1" description = "chongming 网关:FastAPI + Zenoh 的配置驱动微服务网关(插件路由动态挂载、Worker 服务发现与负载均衡)" readme = "README.md" requires-python = ">=3.12" @@ -15,9 +15,10 @@ dependencies = [ "chongming-config", "chongming-jwt", "chongming-worker", + "chongming-license", # Web / 服务器 "fastapi", - "granian[reload,uvloop]>=2.8.4", + "granian[reload,uvloop]", "gunicorn", "httptools", "starlette", @@ -26,9 +27,13 @@ dependencies = [ # 运行时 "amqtt", "eclipse-zenoh", + # chongming-auth → chongming-database 的 sqlalchemy[asyncio] 在 import 期即要求 + "greenlet", "orjson", "pwdlib", "pydantic", + # /license/file 上传端点(File/UploadFile)在注册路由时即要求 + "python-multipart", "sqlite-vfs", ] @@ -47,6 +52,4 @@ url = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" [dependency-groups] -dev = [ - "twine>=7.0.0", -] +dev = ["twine>=7.0.0"] diff --git a/src/chongming/app/__init__.py b/src/chongming/app/__init__.py index 8f9bf29..ab95eb5 100644 --- a/src/chongming/app/__init__.py +++ b/src/chongming/app/__init__.py @@ -24,7 +24,7 @@ from .core.logger import get_logger from sqlite_vfs.contrib.fastapi_static import SVFSStaticFiles from .core import lifespan_setup, call_stats from .core.parent_death import guard_parent_death -from .routes import stats_router, worker_admin_router, auth_router +from .routes import stats_router, worker_admin_router, auth_router, license_router config = get_config() @@ -69,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 = ( @@ -352,3 +355,4 @@ if vfs_db_path := config[env].get("file_system", {}).get("path", None): app.include_router(stats_router) app.include_router(worker_admin_router) app.include_router(auth_router) +app.include_router(license_router) diff --git a/src/chongming/app/core/licensing.py b/src/chongming/app/core/licensing.py new file mode 100644 index 0000000..2874f43 --- /dev/null +++ b/src/chongming/app/core/licensing.py @@ -0,0 +1,264 @@ +"""网关侧机器码授权门禁。 + +职责:把 `chongming_license` 的判定接进网关生命周期 —— 读配置、建管理器、 +在插件发现阶段拒绝未授权插件、输出启动横幅。 + +**"是否执行拒绝"由构建期决定,而不是运行时配置**:部署目录的 `config.toml` +是明文可写的,任何 `enabled = false` 都能被一键绕开。打包脚本在编译前生成 +``chongming/_build_flags.py``(见 :func:`write_build_flags`),把决策和厂商公钥 +一起**编译进产物**,部署侧改不了(**只在打包产物内生效**,源码运行不采纳): + + - 烘焙 ``LICENSE_ENFORCE = True``(授权版构建)→ 强制校验,且公钥随代码内置, + 现场改 config.toml 里的公钥也无法伪造授权; + - 烘焙 ``LICENSE_ENFORCE = False``(无授权版构建)→ 与开发态一致、不校验 —— + **打包不含 license 因此始终是合法选项**,不会产出"拒绝所有插件"的砖头包; + - 未烘焙(开发/测试 / 旧打包脚本产物)→ 打包态沿用"打包即强制"的旧语义; + 非打包态由 ``[license] enabled`` 或 ``CHONGMING_LICENSE_MODE`` 决定,默认放行。 + +安全边界说明:授权信任根是**厂商私钥**(Ed25519 签名)。公钥明文放置本身无害 +(验签失败即拒绝),但若公钥来自可写配置,现场可"换成自己的公钥 + 自签一份 +license"绕过授权 —— 所以授权版构建要把公钥烘焙进代码(上述 True 分支)。 +注意宿主根仓 `resources/*.key` 那套 `.mbank` 验签是 HMAC **对称**且密钥随包发布, +只能算防篡改,**不能**作为授权信任根。 +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +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"} +_FALSY = {"off", "disable", "disabled", "false", "0", "no"} + +_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 build_flags_path() -> Path: + """本包内 ``_build_flags.py`` 的落盘位置(打包脚本在此写烘焙开关)。""" + return Path(__file__).resolve().parents[2] / "_build_flags.py" + + +def _baked_flags() -> tuple[bool | None, str]: + """读取构建期烘焙的授权决策,返回 ``(enforce, public_key)``。 + + ``enforce=None`` 表示未烘焙(开发/测试,或旧打包脚本的产物)。此处用**静态 + 相对 import**(而非 importlib)以便 Nuitka/PyInstaller 沿依赖分析把该模块 + 编译进产物;模块缺失时静默回退。不缓存:每次现读,测试可注入伪模块。 + """ + try: + from ... import _build_flags as flags + except ImportError: + return None, "" + enforce = getattr(flags, "LICENSE_ENFORCE", None) + return ( + None if enforce is None else bool(enforce), + str(getattr(flags, "LICENSE_PUBLIC_KEY", "") or ""), + ) + + +def write_build_flags( + *, + enforce: bool, + public_key: str = "", + target: str | Path | None = None, +) -> Path: + """构建脚本调用:把授权决策烘焙成模块源码(编译进产物,部署侧不可改)。 + + 由打包脚本在**编译前**调用一次(chongming-cli 的 build 已接好): + + - 有厂商公钥(授权版)→ ``enforce=True`` 且公钥一并烘焙:产物强制校验, + 现场改 config.toml 里的公钥也无法伪造授权(自签 + 换公钥这条路被堵死); + - 无公钥(内部/演示/无授权构建)→ ``enforce=False``:产物不校验授权 —— + **打包不配 license 因此始终是合法选项**,且不会产出"拒绝所有插件"的砖头包。 + + Args: + enforce: 该产物是否为授权版(是否强制校验)。 + public_key: 厂商公钥(base64 裸 32 字节或 PEM);非空时会校验格式并记录指纹。 + target: 生成路径,缺省为本包 ``chongming/_build_flags.py``。 + """ + fingerprint = "" + if public_key: + # 失败即构建期配置错误:趁早报错,避免打出一个"谁都验不过"的授权版产物 + from chongming_license import load_public_key, public_key_fingerprint + + try: + load_public_key(public_key) + except Exception as exc: # noqa: BLE001 - 统一转成构建脚本可读的错误 + raise ValueError(f"厂商授权公钥无效:{exc}") from exc + fingerprint = public_key_fingerprint(public_key) + + path = Path(target) if target is not None else build_flags_path() + lines = [ + '"""构建期烘焙的授权决策 —— 由打包脚本生成,请勿手改。', + "", + "编译进产物后部署侧不可修改:部署目录的 config.toml / 环境变量都影响不了", + "这份决策。生成方:chongming_cli.build(见 chongming.app.core.licensing." + "write_build_flags)。", + '"""', + f"LICENSE_ENFORCE = {enforce!r}", + f'LICENSE_PUBLIC_KEY = "{public_key}"', + f'PUBLIC_KEY_FINGERPRINT = "{fingerprint}"', + ] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def license_conf() -> dict: + """``[license]`` 配置段(缺省为空 dict)。""" + return get_config().get("license", {}) or {} + + +def should_enforce() -> bool: + """是否真正执行拒绝。 + + 优先级(见模块文档):**打包产物内**以构建期烘焙为准(未烘焙时沿用"打包即 + 强制"的旧语义);非打包(开发/测试)由环境变量 > 配置决定,默认 False —— + 烘焙标记只对产物生效,源码运行(含可编辑安装)不受上次打包结果影响。 + """ + conf = license_conf() + env_mode = os.environ.get(ENV_MODE, "").strip().lower() + + if is_packaged(): + baked, _ = _baked_flags() + if baked is not None: + if baked: + if conf.get("enabled") is False or env_mode in _FALSY: + # 陷阱日志:授权版产物却看到"关闭授权"的意图 —— 不予采纳,但要留痕 + logger.warning( + "[license] 本产物为授权版构建(构建期已烘焙)," + "配置/环境变量关闭授权不生效。若确需关闭请联系厂商重新出包。" + ) + return True + if conf.get("enabled") or env_mode in _TRUTHY: + logger.warning( + "[license] 本产物为非授权版构建(打包时未配 license)," + "忽略开启授权校验的配置/环境变量(也缺少可校验的公钥)。" + ) + return False + + if conf.get("enabled") is False or env_mode in _FALSY: + # 陷阱日志:打包态却看到"关闭授权"的意图 —— 不予采纳,但要留痕 + logger.warning( + "[license] 检测到关闭授权的配置/环境变量,但当前为打包产物且构建期" + "未烘焙非授权版标记,授权校验强制开启(该配置不生效)。" + "若确需关闭请联系研发重新出包。" + ) + return True + + if env_mode: + return env_mode in _TRUTHY + return bool(conf.get("enabled", False)) + + +def enforce_source() -> str: + """当前 enforce 的判定来源:``build`` / ``packaging`` / ``env`` / ``config``。""" + if is_packaged(): + baked, _ = _baked_flags() + return "build" if baked is not None else "packaging" + if os.environ.get(ENV_MODE, "").strip(): + return "env" + return "config" + + +def _build_manager() -> LicenseManager: + conf = license_conf() + # 公钥优先取构建期烘焙(授权版产物随代码内置、现场不可改);未烘焙时回退 + # 配置/环境注入。与 enforce 一致,只在打包产物内采纳烘焙值。 + conf_key = str(conf.get("public_key") or "") + baked_key = "" + if is_packaged(): + _baked, baked_key = _baked_flags() + if baked_key and conf_key and conf_key != baked_key: + logger.warning( + "[license] 配置内公钥与产物内置公钥不一致,以产物内置公钥为准" + "(内置公钥随代码编译,不可篡改)" + ) + return LicenseManager( + license_path=str(conf.get("path") or DEFAULT_LICENSE_PATH), + public_key=baked_key or conf_key, + 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_source"] = enforce_source() + data["is_packaged"] = 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) diff --git a/src/chongming/app/core/lifespan_setup.py b/src/chongming/app/core/lifespan_setup.py index f6d347f..9f9892c 100644 --- a/src/chongming/app/core/lifespan_setup.py +++ b/src/chongming/app/core/lifespan_setup.py @@ -332,6 +332,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( @@ -341,9 +351,9 @@ async def setup_route_registry( return registry -def _discover_plugins() -> list[dict]: +def _scan_plugins() -> list[dict]: """ - 发现所有插件配置。 + 扫描全部插件配置(**不做授权过滤**,授权门禁见 _discover_plugins)。 优先从各插件的独立配置文件 config/workers//config.toml 的 [workers] 节读取, 兼容旧的 config/workers/config.toml 全局配置。 @@ -431,6 +441,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 / + cleanup_workers 全部经由本函数取插件列表,因此在此剔除未授权插件即可让 + 各处自动保持一致。 + + 必须收口于此而不是"跳过 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 + + def _import_plugin_module(module_name: str): """导入插件模块(`plugins.` 约定)。 diff --git a/src/chongming/app/core/route_registry.py b/src/chongming/app/core/route_registry.py index 7ef3553..2e40b92 100644 --- a/src/chongming/app/core/route_registry.py +++ b/src/chongming/app/core/route_registry.py @@ -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 diff --git a/src/chongming/app/routes/__init__.py b/src/chongming/app/routes/__init__.py index 194527a..8a28799 100644 --- a/src/chongming/app/routes/__init__.py +++ b/src/chongming/app/routes/__init__.py @@ -1,5 +1,6 @@ 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__ = ["stats_router", "worker_admin_router", "auth_router"] \ No newline at end of file +__all__ = ["stats_router", "worker_admin_router", "auth_router", "license_router"] diff --git a/src/chongming/app/routes/licensing.py b/src/chongming/app/routes/licensing.py new file mode 100644 index 0000000..00a26a5 --- /dev/null +++ b/src/chongming/app/routes/licensing.py @@ -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 "无新增插件需要加载,无需重启。" + ), + } diff --git a/src/chongming/app/routes/worker_admin.py b/src/chongming/app/routes/worker_admin.py index 6fb7c55..af3479c 100644 --- a/src/chongming/app/routes/worker_admin.py +++ b/src/chongming/app/routes/worker_admin.py @@ -4,6 +4,7 @@ Worker 管理端点(插件 / 单副本重启) 提供插件实例盘点与"精细化重启单个 worker 副本"的能力: - 经 Zenoh 下发管理指令到目标的 admin Queryable(worker///__admin__) - 仅超级管理员(JWT payload.is_superuser)可访问 + - 未授权插件(机器码授权白名单外)的任何管理操作一律 403 协议说明(见 chongming_worker.runtime 单副本管理协议): - 每个 worker 副本在 worker///__admin__ 注册内部 Queryable @@ -17,6 +18,7 @@ import tomllib from chongming_config import get_config +from ..core import licensing from ..core.auth import jwt_auth_dependency from ..core.logger import get_logger @@ -39,6 +41,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 +74,28 @@ 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": ( + sorted(licensing.denied_types({w["worker_type"] for w in workers})) + if enforce else [] + ), + } @router.post("/admin/workers/restart", summary="重启单个 Worker 副本") @@ -69,6 +110,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 +145,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 +189,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 +230,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 +255,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 +302,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 +352,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 不能为空") diff --git a/test/test_license_gate.py b/test/test_license_gate.py new file mode 100644 index 0000000..a80d229 --- /dev/null +++ b/test/test_license_gate.py @@ -0,0 +1,625 @@ +"""机器码授权门禁测试(网关侧)。 + +核心验证目标 —— 未授权插件被拒绝时,**网关必须仍能正常启动**: + +1. 门禁收口在 `_discover_plugins()`:未授权插件不出现在返回列表中, + 且 `denied_plugin_types()` 能给出拒绝原因; +2. **契约校验不硬失败**:这是最容易踩的坑 —— `validate_contracts()` 会把 + "TOML 路由没有对应 @rpc_method 注册"视为硬性错误并终止启动。若把"不加载 + 未授权插件"实现成"跳过 import 但保留在插件列表里",该插件的 TOML 路由就会 + 变成孤儿 → 设备起不来。本测试确保收口位置正确(并反向验证夹具确实会硬失败, + 防止测试因夹具写错而静默通过); +3. 路由描述符门禁:未授权类型的 external 路由描述符被忽略(防"改 load_mode + 成 external 绕过门禁"); +4. 管理接口门禁:未授权插件的扩缩容/重启/配置接口一律 403,实例盘点如实标注; +5. 授权管理接口:超管门禁、机器码/申请码输出,以及"导入坏文件不覆盖现有授权"; +6. 有效期与开关:过期授权不放行任何插件;未启用校验时(开发/测试)全部放行。 + +测试在临时工作目录里自造夹具(config.toml + config/workers/**),不依赖宿主 +项目文件。运行方式:`python test/test_license_gate.py`。 +""" +import asyncio +import base64 +import io +import json +import os +import sys +import tempfile +import types +from contextlib import contextmanager +from datetime import date, timedelta +from pathlib import Path +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +# ---- 夹具工作目录 --------------------------------------------------------- +# 网关全部相对路径(config.toml / config/workers/** / license.lic / 日志 / 缓存) +# 均以 cwd 为基准,故先切到自造的临时目录,再导入网关模块。 +WORK = Path(tempfile.mkdtemp(prefix="gw-license-")) +os.chdir(WORK) + +(WORK / "config.toml").write_text( + """ +[default] +app.debug = false +app.description = "license 门禁测试夹具" +app.name = "gw-license-test" +app.version = "0.0.1" +env = "development" +prefix = "/api/v1" + +[cache] +backend = "sqlite" + +[security] +algorithm = "HS256" +secret_key = "test-secret" + +[license] +enabled = true +path = "license.lic" +public_key = "" +min_match = 1 + +[development] +debug = true +env = "development" + +[development.server] +access_log = false +host = "127.0.0.1" +port = 8000 +reload = false +workers = 1 + +[development.logging] +file = "logs" +format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +level = "WARNING" +""", + encoding="utf-8", +) + +# 两个插件:alpha 在授权白名单内,beta 不在。beta 声明一条 TOML HTTP 路由而 +# 没有任何 @rpc_method —— 若它未被门禁过滤,契约校验将硬失败终止启动。 +for ptype in ("alpha", "beta"): + d = WORK / "config" / "workers" / ptype + d.mkdir(parents=True) + (d / "config.toml").write_text( + f""" +[workers] +type = "{ptype}" +module = "plugins.{ptype}" +factory = "create_workers" +cleanup = "cleanup" +count = 1 + +[worker] +name = "{ptype}" +version = "0.1.0" + +[worker.registration] +router_prefix = "/{ptype}" +""", + encoding="utf-8", + ) +(WORK / "config" / "workers" / "beta" / "ping.toml").write_text( + '[POST]\nsummary = "beta 端点(无 @rpc_method)"\n', encoding="utf-8" +) + +# 必须在导入网关模块前设置: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 + +import chongming # 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 = 1) -> 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 _request_stub() -> SimpleNamespace: + """管理接口直调用的 Request 桩:只需要 app.state(inline/external 类型观测)。""" + return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) + + +@contextmanager +def _baked_build_flags(enforce: bool, public_key: str = ""): + """模拟打包脚本烘焙的 ``chongming/_build_flags.py``(编译进产物那份)。 + + 直接塞进 sys.modules/父包属性:读取端是静态 import,等价于产物内已编译模块。 + """ + module = types.ModuleType("chongming._build_flags") + module.LICENSE_ENFORCE = enforce + module.LICENSE_PUBLIC_KEY = public_key + prev_mod = sys.modules.get("chongming._build_flags") + had_attr = hasattr(chongming, "_build_flags") + sys.modules["chongming._build_flags"] = module + chongming._build_flags = module + licensing.reset_manager() + try: + yield module + finally: + if prev_mod is None: + sys.modules.pop("chongming._build_flags", None) + else: + sys.modules["chongming._build_flags"] = prev_mod + if not had_attr and hasattr(chongming, "_build_flags"): + del chongming._build_flags + licensing.reset_manager() + + +@contextmanager +def _packaged(value: bool = True): + """模拟运行在打包产物中(Nuitka/PyInstaller 的 sys.frozen/__compiled__)。""" + original = licensing.is_packaged + licensing.is_packaged = lambda: value + licensing.reset_manager() + try: + yield + finally: + licensing.is_packaged = original + licensing.reset_manager() + + +# --------------------------------------------------------------------------- +# 1. 插件发现门禁 +# --------------------------------------------------------------------------- +def test_gate_denies_unlicensed_plugins(): + """未授权插件被剔除,且原因可查。""" + tmp = Path(tempfile.mkdtemp()) + pub, priv = _make_keypair() + _use_license(pub, _issue(tmp, priv, plugins=["alpha"])) + + scanned = sorted(p["type"] for p in _scan_plugins()) + allowed = sorted(p["type"] for p in _discover_plugins()) + denied = denied_plugin_types() + + check("盘扫到全部插件", scanned == ["alpha", "beta"], f"scanned={scanned}") + check("只放行已授权插件", allowed == ["alpha"], f"allowed={allowed}") + check("未授权插件给出拒绝原因", + "beta" in denied and "白名单" in denied["beta"], str(denied)) + + +def test_contract_validation_survives_denial(): + """关键:有插件被拒时契约校验不得抛异常(否则设备起不来)。""" + tmp = Path(tempfile.mkdtemp()) + pub, priv = _make_keypair() + _use_license(pub, _issue(tmp, priv, plugins=["alpha"])) + + 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)) + + # 反向验证:夹具确实构成"硬失败"场景 —— 若 beta 未被剔除,校验必然抛错。 + # 没有这一步,夹具写错(如路由文件缺失)会让上一条检查静默通过。 + beta_entry = [p for p in _scan_plugins() if p["type"] == "beta"] + try: + validate_contracts(beta_entry) + check("夹具自身有效:beta 被纳入时会硬失败", False, "未抛异常") + except RuntimeError as exc: + check("夹具自身有效:beta 被纳入时会硬失败", True, str(exc)[:60]) + + +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)) + + +# --------------------------------------------------------------------------- +# 2. 路由描述符门禁(external 插件) +# --------------------------------------------------------------------------- +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=["alpha"])) + + denied = licensing.denied_types({p["type"] for p in _scan_plugins()}) + check("denied_types 含未授权插件", "beta" 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": "beta", + "subject": "beta.ping", "method": "POST", + "path": "/beta/ping", "params": {}, + } + await registry._handle_sample( + "chongming/routes/beta/ping", + __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)) + + +# --------------------------------------------------------------------------- +# 3. 管理接口门禁 +# --------------------------------------------------------------------------- +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=["alpha"])) + + try: + _ensure_licensed("beta") + check("未授权插件的管理操作被拒", False, "未抛 403") + except HTTPException as exc: + check("未授权插件的管理操作被拒", exc.status_code == 403, f"status={exc.status_code}") + + try: + _ensure_licensed("alpha") + check("已授权插件的管理操作放行", True) + except HTTPException as exc: + check("已授权插件的管理操作放行", False, f"status={exc.status_code}") + + +def test_list_workers_annotates_licensed(): + """实例盘点对每个副本标注 licensed,并汇总拒绝类型(供前端禁用按钮)。""" + from chongming.app.routes.worker_admin import list_workers + + tmp = Path(tempfile.mkdtemp()) + pub, priv = _make_keypair() + _use_license(pub, _issue(tmp, priv, plugins=["alpha"])) + + class _StubManager: + def list_workers(self): + return [ + {"worker_type": "alpha", "worker_id": "worker-0-1", "alive": True}, + {"worker_type": "beta", "worker_id": "worker-0-2", "alive": True}, + ] + + request = _request_stub() + request.app.state.worker_manager = _StubManager() + body = asyncio.run(list_workers(request, _admin={"is_superuser": True})) + + by_type = {w["worker_type"]: w for w in body["workers"]} + check("已授权副本标注 licensed=True", by_type["alpha"]["licensed"] is True) + check("未授权副本标注 licensed=False", by_type["beta"]["licensed"] is False) + check("响应标明门禁启用", body["license_enforced"] is True) + check("响应汇总拒绝类型", body["denied_plugin_types"] == ["beta"], + str(body["denied_plugin_types"])) + + +# --------------------------------------------------------------------------- +# 4. 有效期与开关 +# --------------------------------------------------------------------------- +def test_expired_license_denies_all(): + """过期授权 -> 全部拒绝。""" + tmp = Path(tempfile.mkdtemp()) + pub, priv = _make_keypair() + _use_license(pub, _issue(tmp, priv, plugins=["alpha", "beta"], 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=[])) + os.environ.pop("CHONGMING_LICENSE_MODE", None) + get_config()["license"]["enabled"] = False + licensing.reset_manager() + try: + allowed = sorted(p["type"] for p in _discover_plugins()) + check("未启用校验时全部放行", allowed == ["alpha", "beta"], str(allowed)) + check("未启用校验时无拒绝记录", denied_plugin_types() == {}, + str(denied_plugin_types())) + finally: + os.environ["CHONGMING_LICENSE_MODE"] = "enforce" + get_config()["license"]["enabled"] = True + licensing.reset_manager() + + +# --------------------------------------------------------------------------- +# 5. 授权管理接口(直调端点协程,不需要 TestClient) +# --------------------------------------------------------------------------- +def test_license_endpoints_guard_and_upload(): + """权限门禁、机器码/申请码、以及"坏文件不覆盖现有授权"。""" + from chongming.app.routes.licensing import ( + license_info, + license_upload, + require_superuser, + ) + from fastapi import HTTPException, UploadFile + + tmp = Path(tempfile.mkdtemp()) + pub, priv = _make_keypair() + lic_path = _issue(tmp, priv, plugins=["alpha"]) + _use_license(pub, lic_path) + + # 权限门禁:非超管 403,超管放行 + try: + require_superuser({"is_superuser": False}) + check("非超管访问被拒", False, "未抛 403") + except HTTPException as exc: + check("非超管访问被拒", exc.status_code == 403, f"status={exc.status_code}") + check("超管访问放行", require_superuser({"is_superuser": True})["is_superuser"] is True) + + # 授权信息:机器码 / 申请码 / 插件全景 / 来源明细 + info = asyncio.run(license_info(_request_stub(), _admin={"is_superuser": True})) + check("info 含机器码", info["status"]["machine_code"] != "") + check("info 含申请码", info["request_code"].startswith("CM1-"), + info["request_code"][:16]) + check("info:未授权插件在 denied 中", "beta" in info["plugins"]["denied"], + str(list(info["plugins"]["denied"]))) + check("info:已授权插件在 licensed 中", info["plugins"]["licensed"] == ["alpha"], + str(info["plugins"]["licensed"])) + check("info:来源明细含 weak 标注", + any(s["weak"] for s in info["sources_detail"]), + str([(s["name"], s["weak"]) for s in info["sources_detail"]])) + check("info:标明判定来源", info["enforce_source"] in + ("build", "packaging", "env", "config"), info["enforce_source"]) + + # 签名错误的文件:必须拒绝,且不得覆盖现有授权 + tampered = { + "v": 1, "lic_id": "L-TAMPER", "customer": "x", + "machine_code": "AAAA-BBBB", "sources": {}, "plugins": ["alpha", "beta"], + "not_before": None, "expires_at": None, "issued_at": None, "note": "", + } + bad = UploadFile( + filename="bad.lic", + file=io.BytesIO(json.dumps( + {"payload": tampered, "alg": "ed25519", "kid": "test", "sig": "AAAA"} + ).encode()), + ) + try: + asyncio.run(license_upload(_request_stub(), file=bad, _admin={"is_superuser": True})) + check("签名错误的文件被拒绝", False, "未抛 422") + except HTTPException as exc: + check("签名错误的文件被拒绝", exc.status_code == 422, f"status={exc.status_code}") + check("被拒文件未覆盖现有授权", + json.loads(Path(lic_path).read_text(encoding="utf-8"))["payload"]["lic_id"] + == "L-TEST-0001") + + # 非 JSON 文件 + not_json = UploadFile(filename="bad.lic", file=io.BytesIO(b"not json at all")) + try: + asyncio.run(license_upload(_request_stub(), file=not_json, _admin={"is_superuser": True})) + check("非 JSON 文件被拒绝", False, "未抛 422") + except HTTPException as exc: + check("非 JSON 文件被拒绝", exc.status_code == 422, f"status={exc.status_code}") + + # 合法文件:安装成功,并提示需重启加载新授权的插件 + src = collect_fingerprints() + payload2 = { + "v": 1, "lic_id": "L-TEST-0002", "customer": "测试客户", + "machine_code": machine_code(src), "sources": src, + "plugins": ["alpha", "beta"], "not_before": None, + "expires_at": (date.today() + timedelta(days=365)).isoformat(), + "issued_at": "2026-09-20T00:00:00Z", "note": "", + } + good = UploadFile( + filename="good.lic", + file=io.BytesIO(json.dumps( + {"payload": payload2, "alg": "ed25519", "kid": "test", + "sig": sign_payload(payload2, priv)} + ).encode()), + ) + # 模拟运行中的网关:alpha 已在启动期加载(inline),beta 此前未授权未加载 + running = _request_stub() + running.app.state.inline_types = ["alpha"] + body = asyncio.run(license_upload(running, file=good, _admin={"is_superuser": True})) + check("合法文件导入成功", body["success"] is True) + check("导入后授权插件更新", + body["licensed_plugins"] == ["alpha", "beta"], str(body["licensed_plugins"])) + check("导入后提示需重启加载", + body["restart_required"] and body["pending_plugins"] == ["beta"], + str(body["pending_plugins"])) + + +# --------------------------------------------------------------------------- +# 6. 构建期烘焙开关("打包可选、授权版不可关") +# --------------------------------------------------------------------------- +def test_build_baked_licensed_artifact(): + """授权版产物:烘焙 enforce=True + 公钥 → 强制校验,现场改配置关不掉、换不了公钥。""" + tmp = Path(tempfile.mkdtemp()) + baked_pub, baked_priv = _make_keypair() # 产物内置公钥(厂商签发方) + other_pub, _other_priv = _make_keypair() # 现场试图替换成的"自己的公钥" + lic = _issue(tmp, baked_priv, plugins=["alpha"]) + + with _baked_build_flags(True, baked_pub), _packaged(True): + _use_license(other_pub, lic) # 配置里被换成别的公钥 + get_config()["license"]["enabled"] = False # 并试图关掉校验 + os.environ[licensing.ENV_MODE] = "off" + try: + check("授权版产物强制校验", licensing.should_enforce() is True) + check("判定来源为构建期", licensing.enforce_source() == "build") + allowed = sorted(p["type"] for p in _discover_plugins()) + check("授权版产物按内置公钥判定(换 config 公钥/自签均无效)", + allowed == ["alpha"], str(allowed)) + check("未授权插件仍被拒", "beta" in denied_plugin_types()) + finally: + get_config()["license"]["enabled"] = True + os.environ[licensing.ENV_MODE] = "enforce" + licensing.reset_manager() + + +def test_build_baked_unlicensed_artifact(): + """无授权版产物:烘焙 enforce=False → 不校验(打包不配 license 也是合法选项)。""" + with _baked_build_flags(False), _packaged(True): + get_config()["license"]["enabled"] = True # 配置试图开启也不生效(没有可校验的公钥) + try: + check("非授权版产物不校验", licensing.should_enforce() is False) + check("判定来源为构建期", licensing.enforce_source() == "build") + allowed = sorted(p["type"] for p in _discover_plugins()) + check("非授权版产物全部插件照常加载", allowed == ["alpha", "beta"], str(allowed)) + check("非授权版产物无拒绝记录", denied_plugin_types() == {}, + str(denied_plugin_types())) + finally: + get_config()["license"]["enabled"] = True + + +def test_unbaked_packaged_keeps_enforcing(): + """未烘焙(旧打包脚本产物):沿用"打包即强制"的既有语义。""" + with _packaged(True): + check("未烘焙打包产物仍强制校验", licensing.should_enforce() is True) + check("判定来源为打包形态", licensing.enforce_source() == "packaging") + + +def test_baked_flags_ignored_outside_packaging(): + """烘焙标记只对产物生效:源码运行(含可编辑安装)不受上次打包结果影响。""" + pub, priv = _make_keypair() + tmp = Path(tempfile.mkdtemp()) + lic = _issue(tmp, priv, plugins=["alpha"]) + + with _baked_build_flags(True, pub), _packaged(False): + _use_license(pub, lic) # 源码运行仍由配置/环境变量决定 + check("源码运行不受烘焙标记影响(判定来源非 build)", + licensing.enforce_source() != "build", licensing.enforce_source()) + os.environ[licensing.ENV_MODE] = "off" + try: + check("源码运行按环境变量/配置放行", licensing.should_enforce() is False) + finally: + os.environ[licensing.ENV_MODE] = "enforce" + licensing.reset_manager() + + +def test_write_build_flags_source(): + """write_build_flags 生成的模块源码可执行且值正确;非法公钥被拒绝。""" + from chongming.app.core.licensing import write_build_flags + + pub, _priv = _make_keypair() + target = Path(tempfile.mkdtemp()) / "pkg" / "_build_flags.py" + path = write_build_flags(enforce=True, public_key=pub, target=target) + ns: dict = {} + exec(compile(path.read_text(encoding="utf-8"), str(path), "exec"), ns) + check("生成文件落盘于指定位置", path == target) + check("烘焙 enforce=True", ns["LICENSE_ENFORCE"] is True) + check("烘焙公钥", ns["LICENSE_PUBLIC_KEY"] == pub) + check("记录公钥指纹", bool(ns["PUBLIC_KEY_FINGERPRINT"])) + + path2 = write_build_flags(enforce=False, target=target) + ns2: dict = {} + exec(compile(path2.read_text(encoding="utf-8"), str(path2), "exec"), ns2) + check("无授权版烘焙 enforce=False 且无公钥", + ns2["LICENSE_ENFORCE"] is False and ns2["LICENSE_PUBLIC_KEY"] == "") + + try: + write_build_flags(enforce=True, public_key="not-a-key", target=target) + check("非法公钥被拒绝", False, "未抛异常") + except ValueError as exc: + check("非法公钥被拒绝", "公钥无效" in str(exc), str(exc)[:60]) + + +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()) diff --git a/uv.lock b/uv.lock index b133d38..ceeaf07 100644 --- a/uv.lock +++ b/uv.lock @@ -474,15 +474,18 @@ dependencies = [ { name = "chongming-cache" }, { name = "chongming-config" }, { name = "chongming-jwt" }, + { name = "chongming-license" }, { name = "chongming-worker" }, { name = "eclipse-zenoh" }, { name = "fastapi" }, { name = "granian", extra = ["reload", "uvloop"] }, + { name = "greenlet" }, { name = "gunicorn" }, { name = "httptools" }, { name = "orjson" }, { name = "pwdlib" }, { name = "pydantic" }, + { name = "python-multipart" }, { name = "sqlite-vfs" }, { name = "starlette" }, { name = "uvicorn" }, @@ -501,15 +504,18 @@ requires-dist = [ { name = "chongming-cache" }, { name = "chongming-config" }, { name = "chongming-jwt" }, + { name = "chongming-license", specifier = ">=0.1.0" }, { name = "chongming-worker" }, { name = "eclipse-zenoh" }, { name = "fastapi" }, { name = "granian", extras = ["reload", "uvloop"], specifier = ">=2.8.4" }, + { name = "greenlet", specifier = ">=3.5.6" }, { name = "gunicorn" }, { name = "httptools" }, { name = "orjson" }, { name = "pwdlib" }, { name = "pydantic" }, + { name = "python-multipart", specifier = ">=0.0.32" }, { name = "sqlite-vfs" }, { name = "starlette" }, { name = "uvicorn" }, @@ -531,6 +537,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-password" version = "0.1.0" @@ -587,42 +607,58 @@ dependencies = [ ] sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } wheels = [ + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, ] [[package]] @@ -755,6 +791,73 @@ uvloop = [ { name = "uvloop", marker = "platform_python_implementation == 'CPython' and sys_platform != 'win32'" }, ] +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" } +sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, +] + [[package]] name = "gunicorn" version = "26.2.0" @@ -1304,6 +1407,15 @@ wheels = [ { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" } +sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3"