feat(4.0-P7): 签名目录来源:LLM 安装器泛化为通用插件安装器

- SignedCatalogInstaller 提供目录 / 插件包验签、sha256、防 zip-slip 与落盘;PluginInstaller 从 plugins tag 安装 plugin.toml 插件,LLM 安装器改为其子类,行为不变
- 加载器新增 catalog 来源(builtin > catalog > local > pip),视为已签名
- 新增 GET /plugins/catalog、POST /plugins/{id}/install、DELETE /plugins/{id};安装前拒绝保留 id、非法 id 与内置同名,卸载只删除安装器装入的目录
- scripts/build_plugin_catalog.py 生成并签名 catalog.json
This commit is contained in:
Estrella Pan
2026-10-08 14:23:12 +02:00
parent decc95481a
commit ab9801b63a
7 changed files with 874 additions and 214 deletions

View File

@@ -0,0 +1,77 @@
"""生成并签名插件目录(``plugins`` tag 的发布产物)。
用法(需要更新签名私钥,见 CLAUDE.md 的发布说明)::
uv run --no-project --with cryptography python scripts/build_plugin_catalog.py \
--key ~/.autobangumi/update-signing-key.pem --min-ab 4.0.0 \
--out release-assets dist/*.zip
输入是 ``ab-plugin pack`` 打出的 zip。输出目录里有 ``catalog.json``、每个 zip 以及
它们的 ``.sig``(对文件全部字节做 ed25519 签名,base64 文本),一并上传到 GitHub
release ``plugins`` 即可。安装端的校验见 ``backend/src/module/plugin/installer.py``。
"""
import argparse
import base64
import hashlib
import json
import shutil
import tomllib
import zipfile
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import load_pem_private_key
CATALOG_SCHEMA = 2
def _entry(archive: Path, min_ab: str) -> dict:
with zipfile.ZipFile(archive) as zf:
plugin = tomllib.loads(zf.read("plugin.toml").decode("utf-8"))["plugin"]
return {
"id": plugin["id"],
"name": plugin["name"],
"version": plugin["version"],
"kind": "plugin",
"extension_points": plugin.get("extension_points", []),
"sdk": plugin["sdk"],
"min_ab_version": min_ab,
"description": plugin.get("description", ""),
"asset": archive.name,
"sha256": hashlib.sha256(archive.read_bytes()).hexdigest(),
}
def _sign(key: Ed25519PrivateKey, path: Path) -> None:
signature = key.sign(path.read_bytes())
Path(str(path) + ".sig").write_text(base64.b64encode(signature).decode() + "\n")
def build(archives: list[Path], out: Path, key_path: Path, min_ab: str) -> Path:
key = load_pem_private_key(key_path.read_bytes(), password=None)
if not isinstance(key, Ed25519PrivateKey):
raise SystemExit("签名密钥必须是 ed25519")
out.mkdir(parents=True, exist_ok=True)
entries = []
for archive in sorted(archives):
entries.append(_entry(archive, min_ab))
shutil.copy2(archive, out / archive.name)
_sign(key, out / archive.name)
catalog = out / "catalog.json"
catalog.write_text(
json.dumps({"schema": CATALOG_SCHEMA, "plugins": entries}, indent=2) + "\n",
encoding="utf-8",
)
_sign(key, catalog)
return catalog
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("archives", nargs="+", type=Path)
parser.add_argument("--key", required=True, type=Path)
parser.add_argument("--out", required=True, type=Path)
parser.add_argument("--min-ab", default="4.0.0")
args = parser.parse_args()
print(build(args.archives, args.out, args.key, args.min_ab))