hugo-admin 插件机制
一个基于 gRPC 子进程的、语言无关的扩展系统。插件以独立进程运行,通过本地 gRPC 与 Python/Flask 宿主通信,支持闭源编译型插件(Go 二进制)的分发。
进程隔离
每个插件是独立 OS 进程,崩溃不会拖垮宿主
闭源友好
插件分发编译产物,源码可保持私有
gRPC 强类型
protobuf 契约自动生成多语言客户端桩
能力路由
按 capability 动态挂载 REST 代理
配置加密
Fernet 对称加密,凭据不明文落盘
市场分发
远程 catalog + 手动/签名安装
什么是插件系统 #
hugo-admin 是一个 Python/Flask + React 的 Hugo 博客管理后台。为了让第三方(尤其是闭源)能力能接入而不污染核心代码,它提供了一套插件机制:插件作为独立进程运行,宿主通过本地 gRPC 调用它们的 能力(capability)。
当前内置两种能力:
- capability
image_upload—— 图片上传到外部图床(Cloudflare Images / S3 等) - capability
tts_generation—— 为文章生成语音播报(小米 MiMo TTS + Cloudflare R2)
为什么这样设计 #
| 决策 | 选择 | 理由 |
|---|---|---|
| 通信协议 | gRPC + protobuf | 强类型、双向流、多语言代码生成;比手搓 JSON schema 更不易出错 |
| 运行模型 | 独立子进程 | 进程隔离:插件崩溃不影响宿主;动态端口避免冲突;语言无关 |
| 插件语言 | Go(编译产物) | 分发二进制即可保护 IP;协议本身语言无关,任何 gRPC 语言都行 |
| 网络范围 | 仅 127.0.0.1 | 子进程只监听本地,不对外暴露,安全 |
| 插件发现 | 扫描 ~/.hugo-admin/plugins/ | 用户级、跨项目复用,与博客仓库解耦 |
整体架构 #
┌─────────────────────────────────────────────────────────────┐
│ hugo-admin (Python / Flask) │
│ │
│ ┌──────────────┐ ┌─────────────────────────────────────┐ │
│ │ PluginManager│──▶│ 子进程生命周期:发现/启动/握手/停止 │ │
│ └──────┬───────┘ └─────────────────────────────────────┘ │
│ │ get_*_stub() ┌──────────────────────────┐ │
│ └────────────────────▶│ 能力代理 REST 路由 │ │
│ │ /api/plugins/<name>/... │ │
│ ┌──────────────┐ │ /api/article/tts ... │ │
│ │ PluginConfig │ └─────────────┬────────────┘ │
│ │ Store(Fernet)│ │ │
│ └──────────────┘ │ gRPC │
└──────────────────────────────────────────────┼──────────────┘
│ 127.0.0.1:<port>
┌──────────────────────────▼───────────────┐
│ 插件子进程(独立 OS 进程) │
│ │
│ PluginService (核心,必须实现) │
│ Info · HealthCheck · GetConfigSchema │
│ SetConfig │
│ │
│ ImageUploader (可选能力) │
│ TTSGenerator (可选能力) │
│ │
│ → 外部 API (Cloudflare / MiMo / S3 ...) │
└───────────────────────────────────────────┘
生命周期 #
宿主启动时,PluginManager 执行以下流程(对应 app.py 启动时的 plugin_manager.start_all()):
发现 Discovery
扫描 ~/.hugo-admin/plugins/*/plugin.toml,解析并校验每个清单;非法目录跳过并告警。
分配端口
用临时 socket 申请一个空闲的本地端口,传给插件作为 --port 参数。
启动子进程
subprocess.Popen([entry, "--port", port])。入口路径会做 realpath 解引用 + 目录边界校验,防穿越。
健康握手
轮询 HealthCheck RPC(10 秒超时),成功后调 Info() 拿到能力列表。
下发配置
若该插件有已保存配置,解密后通过 SetConfig 推送(明文,仅在本地通道)。
优雅停止
停机时 SIGTERM → 等 5 秒 → SIGKILL。
gRPC 协议 #
所有插件必须实现核心服务 PluginService;能力服务是可选的,按需实现:
service PluginService { // 每个 plugin 必须实现
rpc Info(Empty) returns (PluginInfo);
rpc HealthCheck(Empty) returns (HealthResponse);
rpc GetConfigSchema(Empty) returns (ConfigSchemaResponse);
rpc SetConfig(SetConfigRequest) returns (SetConfigResponse);
}
service ImageUploader { // 可选能力:客户端流式上传
rpc Upload(stream ImageUploadChunk) returns (ImageUploadResponse);
rpc Delete(ImageDeleteRequest) returns (ImageDeleteResponse);
}
service TTSGenerator { // 可选能力:服务端流式(边合成边报进度)
rpc Generate(TTSRequest) returns (stream TTSResponse);
rpc Delete(TTSDeleteRequest) returns (TTSDeleteResponse);
}
PluginInfo 携带元数据,其中 capabilities 决定宿主如何路由,priority 用于多个插件提供同一能力时择优:
message PluginInfo {
string name = 1;
string version = 2;
string description = 3;
string author = 4;
string protocol_version = 5; // 必须与宿主期望版本匹配
repeated string capabilities = 6; // 如 "image_upload"、"tts_generation"
int32 priority = 7; // 越大越优先
}
protocol_version 不必升。旧插件不实现新 service,宿主只在插件声明了该能力时才调用对应 stub。
清单 plugin.toml #
每个插件目录必须有一个 plugin.toml,声明身份、入口、能力与配置 schema:
[plugin]
name = "my-plugin"
version = "1.0.0"
author = "svtter"
description = "做点什么"
entry = "./bin/my-plugin" # 相对插件目录;会被 realpath 校验不得越界
protocol_version = "1"
priority = 100
[build]
platform = "linux" # 启动前校验平台/架构
arch = "amd64"
[capabilities]
image_upload = true # 值为 true 的键即为声明的能力
tts_generation = true
[config_schema]
schema = '''
{ "type": "object", "properties": { "api_key": { "type": "string" } } }
'''
配置与加密 #
插件的凭据(API token、account id 等)通过三层配置管理:
| 层 | 说明 |
|---|---|
| Schema 声明 | [config_schema].schema 是 JSON Schema;前端据此自动渲染表单,字段名含 token/key 自动变密码框 |
| 运行时下发 | PluginService.GetConfigSchema(优先于 manifest)+ SetConfig;宿主解密后明文下发 |
| 持久化 | ~/.hugo-admin/plugin-config.json,所有 string 值用 Fernet 对称加密,密钥在 ~/.hugo-admin/.secret_key(0600) |
能力路由 #
宿主按 capability 把请求路由到对应插件,分两层:
- 能力代理(直通插件,不碰文章):如
POST /api/plugins/<name>/image/upload、POST /api/plugins/<name>/tts/generate - 文章集成(读正文 → 调插件 → 写回 frontmatter):如
POST /api/article/tts,宿主透明地用插件结果更新文章
多个插件提供同一能力时,PluginManager.find_plugin_with_capability() 选第一个 running 的(可结合 priority)。无插件时,相关按钮在前端自动隐藏。
编写一个插件 #
以 Go 为例(最常见,便于分发闭源二进制)。核心步骤:
1. 生成 gRPC 桩
protoc --proto_path=proto \
--go_out=proto --go_opt=paths=source_relative \
--go-grpc_out=proto --go-grpc_opt=paths=source_relative \
proto/plugin.proto
2. 实现 PluginService + 你的能力服务
Info() 返回能力列表;HealthCheck 返回 healthy;SetConfig 缓存解密后的配置;按声明的能力实现对应 service 的方法。
3. main: 解析 --port,起 gRPC server
func main() {
flag.Int("port", 0, "host 分配的端口")
lis, _ := net.Listen("tcp", fmt.Sprint("127.0.0.1:%d", port))
s := grpc.NewServer()
pb.RegisterPluginServiceServer(s, &server{})
pb.RegisterTTSGeneratorServer(s, &server{}) // 你的能力
s.Serve(lis)
}
4. 编译 + 写 plugin.toml
编译到 bin/,与 plugin.toml 同目录。
安装与启用 #
~/.hugo-admin/ ├── plugins/ │ └── tts-gen-plugin/ # 每个插件一个目录 │ ├── plugin.toml │ └── bin/ │ └── tts-gen-plugin # 编译产物,需可执行 ├── plugin-config.json # 全部插件的加密配置 └── .secret_key # Fernet 密钥 (0600)
- 把
plugin.toml+bin/放到~/.hugo-admin/plugins/<name>/ - 重启 hugo-admin(v1 不支持热加载)
- 进入「插件」页 → 启用 → 填配置(敏感字段自动加密)→ 保存
plugins.hugo-admin.dev/manifest.json 拉 catalog(5 分钟 TTL 缓存)。设计上支持 Minisign 签名 + SHA256 校验安装;当前安装为手动解压到上述目录。
示例:TTS 语音播报插件 #
这是新接入的能力(tts_generation),是插件机制的最佳范例。插件内部调小米 MiMo TTS 合成音频、上传 Cloudflare R2,宿主只拿到一个公网 URL:
编辑器点「生成语音」
│
▼ POST /api/article/tts
宿主读正文(剥离 frontmatter)
│ 找到 running 的 tts_generation 插件
▼ gRPC TTSGenerator.Generate (服务端流)
插件: MiMo 合成 → 上传 R2 → 流式回 progress/result
│ 宿主经 Socket.IO 转发进度到前端
▼ 拿到 url
宿主用乐观锁把 audio:url 写回文章 frontmatter
│ emit tts.done {url, mtime}
▼
前端刷新 <audio> 播放器 + fileMtime(防假冲突)
关键设计:插件「生成 + 托管」一体返回 URL,与 image_upload 完全对称;长任务走 gRPC 服务端流 + Socket.IO 转发;写回 frontmatter 用乐观锁,并把新 mtime 回传前端刷新(避免下次保存假冲突)。
配套闭源插件:sun-praise/tts-gen-plugin · host 端实现:PR #149