hugo-admin 插件机制

一个基于 gRPC 子进程的、语言无关的扩展系统。插件以独立进程运行,通过本地 gRPC 与 Python/Flask 宿主通信,支持闭源编译型插件(Go 二进制)的分发。

🔌

进程隔离

每个插件是独立 OS 进程,崩溃不会拖垮宿主

🔒

闭源友好

插件分发编译产物,源码可保持私有

📡

gRPC 强类型

protobuf 契约自动生成多语言客户端桩

🧩

能力路由

按 capability 动态挂载 REST 代理

🔐

配置加密

Fernet 对称加密,凭据不明文落盘

📦

市场分发

远程 catalog + 手动/签名安装

什么是插件系统 #

hugo-admin 是一个 Python/Flask + React 的 Hugo 博客管理后台。为了让第三方(尤其是闭源)能力能接入而不污染核心代码,它提供了一套插件机制:插件作为独立进程运行,宿主通过本地 gRPC 调用它们的 能力(capability)

当前内置两种能力:

核心思想:宿主只管协议与编排,插件负责外部世界的具体细节。比如 TTS 插件内部调谁家的 TTS、音频存哪——宿主完全不感知,它只拿到一个公网 URL 写进文章 frontmatter。

为什么这样设计 #

决策选择理由
通信协议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;能力服务是可选的,按需实现:

proto/plugin.proto
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;              // 越大越优先
}
向后兼容:新增能力 = 在 proto 里加一个可选 service,protocol_version 不必升。旧插件不实现新 service,宿主只在插件声明了该能力时才调用对应 stub。

清单 plugin.toml #

每个插件目录必须有一个 plugin.toml,声明身份、入口、能力与配置 schema:

~/.hugo-admin/plugins/my-plugin/plugin.toml
[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)
设计取舍:v1 对所有 string 值加密(而非按 schema 的 sensitive 标记),宁可多加密也不漏。线程安全 + 原子写(tmp + replace)。

能力路由 #

宿主按 capability 把请求路由到对应插件,分两层:

  1. 能力代理(直通插件,不碰文章):如 POST /api/plugins/<name>/image/uploadPOST /api/plugins/<name>/tts/generate
  2. 文章集成(读正文 → 调插件 → 写回 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)
  1. plugin.toml + bin/ 放到 ~/.hugo-admin/plugins/<name>/
  2. 重启 hugo-admin(v1 不支持热加载)
  3. 进入「插件」页 → 启用 → 填配置(敏感字段自动加密)→ 保存
市场分发:宿主从 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