feat: add OAuth outbound transport plugin system

This commit is contained in:
shaw
2026-08-24 09:03:37 +08:00
parent d45135d87d
commit 40ea3aebad
98 changed files with 7681 additions and 141 deletions
+80
View File
@@ -0,0 +1,80 @@
# Sub2API 本地插件协议
本目录是插件开发者可以依赖的公开契约。`v1/plugin.proto` 和 `v1/runtime.go` 定义进程协议,`v1/manifest.schema.json` 定义包清单,`docs/` 记录开发和发布规范。Provider 私有实现不应放入本目录。
## 开发文档
- [开发指南](docs/development.md):从运行时、配置到集成测试的完整流程。
- [UI Bridge](docs/ui-bridge.md):沙箱配置 UI 的消息结构和安全要求。
- [包格式](docs/package-format.md):清单、文件哈希、签名和版本规则。
- [安全边界](docs/security.md):进程权限、敏感数据和故障策略。
## 实体与运行方式
插件的交付实体是一个 `.s2plugin` 文件,本质上是带清单、签名、独立可执行文件和静态 UI 的 ZIP 包。管理员在独立的插件管理页手动上传,Sub2API 不从网络自动下载插件,也不要求 Docker。
启用后,Sub2API 以子进程方式拉起当前操作系统和 CPU 架构对应的二进制,通过本机 gRPC 流传递请求与响应。插件进程退出时会随 Sub2API 清理;停用时先停止接收新请求,再等待正在处理的请求结束。
多实例部署不要求共享插件目录。宿主会在数据库保存已验签的原始插件包,各实例缺少本地文件时会重新验签和解包,并周期性对齐启用状态、灰度比例和加密配置。所有实例必须连接同一数据库并使用相同的加密密钥。
独立进程是代码和发布边界,不是操作系统安全沙箱。插件拥有 Sub2API 服务用户所拥有的文件和网络权限,因此只应安装可信发布者的签名包。闭源二进制可提高源码分发门槛,但不能承诺无法反编译。
## 初期能力边界
当前只接受 `openai.oauth.outbound_transport.v1`:
- 仅匹配 `platform=openai` 且 `account_type=oauth` 的上游 HTTP 请求。
- API Key 账号、其他 provider、OAuth 登录与 Token 刷新流程不进入插件。
- 插件建立真实的上游 HTTP/TLS 连接并返回原始 HTTP 响应。
- 命中插件的 OAuth WebSocket 账号会使用 Sub2API 现有 HTTP Bridge,不直接建立上游 WebSocket,避免绕过 v1 HTTP 插件协议。
- Sub2API 继续负责响应状态处理、SSE 解析、错误映射、用量统计、计费和下游输出。
- 灰度比例以账号 ID 稳定分桶,未命中的 OAuth 账号继续使用原有内置路径。
## 包结构
```text
manifest.json
signature.json # 生产包必需
runtimes/linux-amd64/plugin
runtimes/linux-arm64/plugin
runtimes/windows-amd64/plugin.exe
ui/index.html
ui/assets/...
```
`manifest.json` 必须声明所有运行时和 UI 文件的 SHA-256。`signature.json` 使用受信任发布者的 Ed25519 私钥对 `manifest.json` 原始字节签名。官方 OpenAI Transport 公钥由宿主内置,第三方发布者公钥由部署者追加到 `plugins.trusted_publishers`。文件哈希由已签名清单保护。
插件默认保持停用。未签名包默认拒绝安装;`plugins.allow_unsigned` 只应用于开发者自己构建的本地调试包。
## 兼容性
清单必须同时声明:
- `requires.sub2api`:允许的 Sub2API 语义化版本范围。
- `requires.recommended_sub2api_version`:建议使用的宿主版本。
- `requires.tested_sub2api_versions`:发布者实际验证过的宿主版本。
- `plugin_protocol`、`transport_api`、`ui_bridge`:三个独立协议版本。
宿主版本超出范围时,插件可以安装并查看,但保持“不兼容”状态且不能启用。版本在范围内但未列入已测试版本时,管理员必须再次确认才能启用。
## UI 隔离与 Bridge
插件 UI 由包内静态文件实现,宿主使用只有 `allow-scripts` 权限的 sandbox iframe 加载。iframe 没有管理员 Token,也不能直接访问管理 API。宿主为每次打开配置页生成短时资源 URL 和独立 Bridge Token,并且同时校验消息来源窗口与 Token。
UI 可以发送以下消息:
- `config.load`
- `config.save`
- `config.test`
- `ui.resize`
- `ui.notify`
每个请求消息带 `request_id`,宿主以 `<type>.result` 返回结果。配置整体使用 Sub2API 的密钥加密后存入数据库;运行中插件会先验证并应用新配置,数据库写入失败时恢复旧配置。
## 协议源码
- `v1/plugin.proto`:稳定的进程间消息定义。
- `v1/runtime.go`:Go 插件进程启动入口和宿主客户端声明。
- `v1/manifest.schema.json`:`manifest.json` 的 JSON Schema。
插件通过进程协议协作,不使用 Go 动态链接,也不要求插件与 Sub2API 使用相同编译器或共享内存 ABI。
+67
View File
@@ -0,0 +1,67 @@
# 插件开发指南
## 稳定边界
当前宿主只支持 `openai.oauth.outbound_transport.v1`。插件负责建立实际上游 HTTP/TLS 连接,Sub2API 负责账号选择、OAuth Token 生命周期、下游协议、响应解析、SSE、错误映射、用量统计和计费。
插件不应修改 API Key 路径,也不应自行刷新或持久化 OAuth Token。
## 推荐结构
```text
plugin/
├── cmd/<plugin>/main.go
├── internal/config/
├── internal/transport/
├── ui/index.html
├── ui/assets/
├── tools/packager/
├── manifest.source.json
└── README.md
```
入口只调用 `pluginv1.Serve`。配置解析和传输实现放入独立包,以便不启动子进程就能单元测试。
## 运行时方法
| 方法 | 要求 |
|---|---|
| `GetInfo` | ID、版本、协议和能力必须与清单一致 |
| `Health` | 返回进程是否可以接受新请求,不执行昂贵探测 |
| `ValidateConfig` | 严格解析并返回完整规范化 JSON |
| `ApplyConfig` | 原子应用配置;失败时保留旧配置 |
| `TestConfig` | 验证当前环境和已保存配置,返回简短诊断 |
| `Forward` | 双向流式传输请求与原始 HTTP 响应 |
请求帧顺序:`start`、零到多个 `body_chunk`、`body_end`。响应帧顺序:`start`、零到多个 `body_chunk`、`end`。不能继续处理的错误使用 `error` 帧。
`request_sent` 必须如实表示请求是否可能已经到达上游。值为 `true` 时宿主禁止自动切换账号重放;只有能确认尚未调用上游 Transport 时才能返回 `false`。
## 配置
- JSON 字段统一使用 `snake_case`。
- 拒绝未知字段、非法范围和受保护请求头。
- 默认配置必须完整,空对象应规范化为所有默认字段。
- 保存时由插件先验证和应用,再由宿主加密写入数据库。
- 数据库写入失败时宿主会尝试恢复旧配置,插件必须允许重复应用。
## 资源管理
- 复用 HTTP Transport 和连接池,不要为每个请求创建新连接池。
- 配置切换后关闭旧空闲连接。
- 使用 stream context 取消 DNS、连接、上传和响应读取。
- 始终关闭上游响应体。
- 不在插件内无限缓存按账号区分的客户端。
## 最低测试集
- 配置默认值、未知字段、边界值和深复制。
- 插件身份及协议版本。
- 请求体分块、无请求体、固定 Content-Length。
- 响应状态、重复请求头、流式响应和响应读取错误。
- 上下文取消、插件退出和超时。
- 代理开启与禁用。
- 包哈希、签名、路径穿越和目标平台运行时。
- UI Bridge 加载、保存、测试、错误和超时。
发布前还应使用真实构建包运行宿主的插件进程集成测试。
@@ -0,0 +1,45 @@
# `.s2plugin` 包格式
`.s2plugin` 是 ZIP 文件,根目录必须包含 `manifest.json`,生产包还必须包含 `signature.json`。
## 标准布局
```text
manifest.json
signature.json
runtimes/<goos>-<goarch>/<binary>
ui/index.html
ui/assets/...
```
所有运行时和 UI 文件必须出现在 `manifest.files`,值为小写十六进制 SHA-256。清单和签名文件自身不写入 `files`。
包不允许绝对路径、父目录跳转、重复路径、符号链接、未声明文件或缺失文件。宿主还限制上传大小、解压后大小和文件数量。
## 清单
字段规范见 [`v1/manifest.schema.json`](../v1/manifest.schema.json)。版本字段含义:
- `version`:插件自身语义化版本。
- `requires.sub2api`:宿主硬兼容范围。
- `recommended_sub2api_version`:建议宿主版本。
- `tested_sub2api_versions`:发布者真实验证过的版本。
- `plugin_protocol`:进程握手协议。
- `transport_api`:请求和响应帧协议。
- `ui_bridge`:配置 UI 消息协议。
## 签名
`signature.json`:
```json
{
"algorithm": "ed25519",
"key_id": "publisher-key-id",
"signature": "BASE64_SIGNATURE"
}
```
签名对象是 `manifest.json` 的精确原始字节。发布者私钥不得进入插件包、源码仓库或 Sub2API 运行环境。部署者只配置 Base64 Ed25519 公钥。
默认生产配置拒绝未签名包。官方 OpenAI Transport 使用宿主内置公钥验签,不需要配置;其他发布者仍需配置 `trusted_publishers`。`allow_unsigned` 只用于开发者自己构建的本地包。
+29
View File
@@ -0,0 +1,29 @@
# 插件安全边界
## 能提供的隔离
- 私有实现以独立二进制交付,宿主公开源码不包含其业务逻辑。
- 进程协议避免 Go 动态链接和共享内存 ABI。
- 包签名和文件哈希防止未授权替换。
- UI 使用短时 URL、独立 Bridge Token 和 sandbox iframe。
- 插件故障时 OAuth 插件路径失败关闭,不静默切回另一种网络行为。
## 不能提供的保证
- 闭源二进制仍可能被逆向分析。
- 子进程不是操作系统沙箱。
- 插件拥有 Sub2API 服务用户可访问的文件、环境变量和网络权限。
- 包签名证明发布者身份,不证明实现无漏洞或符合 Provider 条款。
## 部署要求
- 官方 OpenAI Transport 使用宿主内置公钥;只向 `plugins.trusted_publishers` 添加经过审核的第三方公钥。
- 使用专用低权限系统用户运行 Sub2API。
- 限制该用户的文件权限、出站网络和环境变量。
- 不向插件环境注入无关密钥。
- 对插件升级保留旧包和回滚流程。
- 记录安装、启用、停用、配置和删除操作,但不记录配置明文。
## 敏感数据
插件处理真实 OAuth Authorization 请求头,必须避免将请求头、请求体、代理凭据和上游敏感响应写入日志或诊断消息。UI 配置中不应出现 OAuth Token。
+56
View File
@@ -0,0 +1,56 @@
# UI Bridge v1
## 加载方式
宿主为每次打开配置页创建短时 UI 会话:
```text
/api/v1/plugin-ui/<asset-token>/index.html#bridge_token=<bridge-token>
```
资源 Token 用于读取包内 `ui/` 文件,Bridge Token 只存在于 URL fragment,不会发送到服务器。iframe 使用 `sandbox="allow-scripts"`,不授予 `allow-same-origin`。
UI 只能加载包内、已在清单声明的资源。CSP 禁止外部网络连接、表单提交和外部 frame。
## 消息信封
UI 到宿主:
```json
{
"source": "sub2api-plugin-ui",
"bridge_token": "TOKEN",
"type": "config.load",
"request_id": "UNIQUE_ID"
}
```
宿主到 UI:
```json
{
"source": "sub2api-plugin-host",
"bridge_token": "TOKEN",
"request_id": "UNIQUE_ID",
"ok": true
}
```
## 方法
| `type` | UI 参数 | 成功响应 |
|---|---|---|
| `sub2api.plugin.ready` | 无 | 无响应 |
| `config.load` | 无 | `config` |
| `config.save` | `config` 对象 | 规范化后的 `config` |
| `config.test` | 无 | `result` |
| `ui.resize` | `height` | 无响应 |
| `ui.notify` | `level`、`message` | 无响应 |
`config.test` 在 v1 中测试已保存配置。UI 若要测试当前表单,应先调用 `config.save`。
## 必须执行的校验
UI 接收消息时必须验证 `event.source === parent`、消息来源标识、Bridge Token 和等待中的 `request_id`。每个请求必须有超时和卸载清理。
宿主不会向 iframe 提供管理员 Token。插件 UI 不得尝试访问管理 API、Cookie、父页面 DOM 或浏览器存储中的宿主数据。
@@ -0,0 +1,75 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sub2api.local/schemas/plugin-manifest-v1.json",
"title": "Sub2API Plugin Manifest v1",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "id", "name", "version", "requires", "capabilities", "runtimes", "ui", "files"],
"properties": {
"schema_version": { "const": 1 },
"id": { "type": "string", "maxLength": 160, "pattern": "^[a-z0-9]+([._-][a-z0-9]+)+$" },
"name": { "type": "string", "minLength": 1, "maxLength": 160 },
"version": { "type": "string", "pattern": "^v?(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$" },
"description": { "type": "string" },
"author": { "type": "string", "maxLength": 160 },
"requires": {
"type": "object",
"additionalProperties": false,
"required": ["sub2api", "plugin_protocol", "transport_api", "ui_bridge"],
"properties": {
"sub2api": { "type": "string", "minLength": 1 },
"recommended_sub2api_version": { "type": "string" },
"tested_sub2api_versions": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
"plugin_protocol": { "const": 1 },
"transport_api": { "const": 1 },
"ui_bridge": { "const": 1 }
}
},
"capabilities": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "platform", "account_type"],
"properties": {
"id": { "const": "openai.oauth.outbound_transport.v1" },
"platform": { "const": "openai" },
"account_type": { "const": "oauth" }
}
}
},
"runtimes": {
"type": "object",
"minProperties": 1,
"patternProperties": {
"^[a-z0-9]+-[a-z0-9]+$": {
"type": "object",
"additionalProperties": false,
"required": ["path"],
"properties": { "path": { "$ref": "#/$defs/safePath" } }
}
},
"additionalProperties": false
},
"ui": {
"type": "object",
"additionalProperties": false,
"required": ["entrypoint"],
"properties": { "entrypoint": { "type": "string", "pattern": "^ui/.+" } }
},
"files": {
"type": "object",
"minProperties": 2,
"propertyNames": { "$ref": "#/$defs/safePath" },
"additionalProperties": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
}
},
"$defs": {
"safePath": {
"type": "string",
"minLength": 1,
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"
}
}
}
@@ -0,0 +1,21 @@
package pluginv1
import (
"encoding/json"
"os"
"testing"
)
func TestManifestSchemaIsValidJSON(t *testing.T) {
raw, err := os.ReadFile("manifest.schema.json")
if err != nil {
t.Fatalf("读取插件清单 Schema 失败: %v", err)
}
var schema map[string]any
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("插件清单 Schema 不是有效 JSON: %v", err)
}
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" {
t.Fatalf("插件清单 Schema 版本不符合预期: %v", schema["$schema"])
}
}
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
syntax = "proto3";
package sub2api.plugin.v1;
option go_package = "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1;pluginv1";
service TransportPlugin {
rpc GetInfo(GetInfoRequest) returns (GetInfoResponse);
rpc Health(HealthRequest) returns (HealthResponse);
rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse);
rpc ApplyConfig(ApplyConfigRequest) returns (ApplyConfigResponse);
rpc TestConfig(TestConfigRequest) returns (TestConfigResponse);
rpc Forward(stream ForwardRequest) returns (stream ForwardResponse);
}
message GetInfoRequest {}
message GetInfoResponse {
string plugin_id = 1;
string plugin_version = 2;
uint32 protocol_version = 3;
uint32 transport_api_version = 4;
repeated string capabilities = 5;
}
message HealthRequest {}
message HealthResponse {
bool healthy = 1;
string message = 2;
}
message ValidateConfigRequest {
bytes config_json = 1;
}
message ValidateConfigResponse {
bool valid = 1;
string message = 2;
bytes normalized_config_json = 3;
}
message ApplyConfigRequest {
bytes config_json = 1;
}
message ApplyConfigResponse {
bool applied = 1;
string message = 2;
}
message TestConfigRequest {
bytes config_json = 1;
}
message TestConfigResponse {
bool success = 1;
string message = 2;
int64 latency_ms = 3;
}
message HeaderValues {
repeated string values = 1;
}
message ForwardRequestStart {
string request_id = 1;
string method = 2;
string url = 3;
string host = 4;
map<string, HeaderValues> headers = 5;
string proxy_url = 6;
int64 account_id = 7;
int32 account_concurrency = 8;
string platform = 9;
string account_type = 10;
int64 content_length = 11;
bool has_body = 12;
}
message ForwardRequest {
oneof frame {
ForwardRequestStart start = 1;
bytes body_chunk = 2;
bool body_end = 3;
}
}
message ForwardResponseStart {
int32 status_code = 1;
string status = 2;
string protocol = 3;
int32 protocol_major = 4;
int32 protocol_minor = 5;
map<string, HeaderValues> headers = 6;
int64 content_length = 7;
}
message ForwardResponseEnd {
int64 bytes_received = 1;
int64 duration_ms = 2;
}
message ForwardResponseError {
string code = 1;
string message = 2;
bool request_sent = 3;
}
message ForwardResponse {
oneof frame {
ForwardResponseStart start = 1;
bytes body_chunk = 2;
ForwardResponseEnd end = 3;
ForwardResponseError error = 4;
}
}
+306
View File
@@ -0,0 +1,306 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v3.12.4
// source: plugin.proto
package pluginv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
TransportPlugin_GetInfo_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/GetInfo"
TransportPlugin_Health_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Health"
TransportPlugin_ValidateConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ValidateConfig"
TransportPlugin_ApplyConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ApplyConfig"
TransportPlugin_TestConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/TestConfig"
TransportPlugin_Forward_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Forward"
)
// TransportPluginClient is the client API for TransportPlugin service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type TransportPluginClient interface {
GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error)
Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error)
ValidateConfig(ctx context.Context, in *ValidateConfigRequest, opts ...grpc.CallOption) (*ValidateConfigResponse, error)
ApplyConfig(ctx context.Context, in *ApplyConfigRequest, opts ...grpc.CallOption) (*ApplyConfigResponse, error)
TestConfig(ctx context.Context, in *TestConfigRequest, opts ...grpc.CallOption) (*TestConfigResponse, error)
Forward(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ForwardRequest, ForwardResponse], error)
}
type transportPluginClient struct {
cc grpc.ClientConnInterface
}
func NewTransportPluginClient(cc grpc.ClientConnInterface) TransportPluginClient {
return &transportPluginClient{cc}
}
func (c *transportPluginClient) GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetInfoResponse)
err := c.cc.Invoke(ctx, TransportPlugin_GetInfo_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *transportPluginClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HealthResponse)
err := c.cc.Invoke(ctx, TransportPlugin_Health_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *transportPluginClient) ValidateConfig(ctx context.Context, in *ValidateConfigRequest, opts ...grpc.CallOption) (*ValidateConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ValidateConfigResponse)
err := c.cc.Invoke(ctx, TransportPlugin_ValidateConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *transportPluginClient) ApplyConfig(ctx context.Context, in *ApplyConfigRequest, opts ...grpc.CallOption) (*ApplyConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyConfigResponse)
err := c.cc.Invoke(ctx, TransportPlugin_ApplyConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *transportPluginClient) TestConfig(ctx context.Context, in *TestConfigRequest, opts ...grpc.CallOption) (*TestConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TestConfigResponse)
err := c.cc.Invoke(ctx, TransportPlugin_TestConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *transportPluginClient) Forward(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ForwardRequest, ForwardResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &TransportPlugin_ServiceDesc.Streams[0], TransportPlugin_Forward_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[ForwardRequest, ForwardResponse]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type TransportPlugin_ForwardClient = grpc.BidiStreamingClient[ForwardRequest, ForwardResponse]
// TransportPluginServer is the server API for TransportPlugin service.
// All implementations must embed UnimplementedTransportPluginServer
// for forward compatibility.
type TransportPluginServer interface {
GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error)
Health(context.Context, *HealthRequest) (*HealthResponse, error)
ValidateConfig(context.Context, *ValidateConfigRequest) (*ValidateConfigResponse, error)
ApplyConfig(context.Context, *ApplyConfigRequest) (*ApplyConfigResponse, error)
TestConfig(context.Context, *TestConfigRequest) (*TestConfigResponse, error)
Forward(grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]) error
mustEmbedUnimplementedTransportPluginServer()
}
// UnimplementedTransportPluginServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedTransportPluginServer struct{}
func (UnimplementedTransportPluginServer) GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetInfo not implemented")
}
func (UnimplementedTransportPluginServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
}
func (UnimplementedTransportPluginServer) ValidateConfig(context.Context, *ValidateConfigRequest) (*ValidateConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ValidateConfig not implemented")
}
func (UnimplementedTransportPluginServer) ApplyConfig(context.Context, *ApplyConfigRequest) (*ApplyConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ApplyConfig not implemented")
}
func (UnimplementedTransportPluginServer) TestConfig(context.Context, *TestConfigRequest) (*TestConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method TestConfig not implemented")
}
func (UnimplementedTransportPluginServer) Forward(grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]) error {
return status.Error(codes.Unimplemented, "method Forward not implemented")
}
func (UnimplementedTransportPluginServer) mustEmbedUnimplementedTransportPluginServer() {}
func (UnimplementedTransportPluginServer) testEmbeddedByValue() {}
// UnsafeTransportPluginServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to TransportPluginServer will
// result in compilation errors.
type UnsafeTransportPluginServer interface {
mustEmbedUnimplementedTransportPluginServer()
}
func RegisterTransportPluginServer(s grpc.ServiceRegistrar, srv TransportPluginServer) {
// If the following call panics, it indicates UnimplementedTransportPluginServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&TransportPlugin_ServiceDesc, srv)
}
func _TransportPlugin_GetInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetInfoRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TransportPluginServer).GetInfo(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TransportPlugin_GetInfo_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TransportPluginServer).GetInfo(ctx, req.(*GetInfoRequest))
}
return interceptor(ctx, in, info, handler)
}
func _TransportPlugin_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HealthRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TransportPluginServer).Health(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TransportPlugin_Health_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TransportPluginServer).Health(ctx, req.(*HealthRequest))
}
return interceptor(ctx, in, info, handler)
}
func _TransportPlugin_ValidateConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ValidateConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TransportPluginServer).ValidateConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TransportPlugin_ValidateConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TransportPluginServer).ValidateConfig(ctx, req.(*ValidateConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _TransportPlugin_ApplyConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TransportPluginServer).ApplyConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TransportPlugin_ApplyConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TransportPluginServer).ApplyConfig(ctx, req.(*ApplyConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _TransportPlugin_TestConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TestConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TransportPluginServer).TestConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TransportPlugin_TestConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TransportPluginServer).TestConfig(ctx, req.(*TestConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _TransportPlugin_Forward_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(TransportPluginServer).Forward(&grpc.GenericServerStream[ForwardRequest, ForwardResponse]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type TransportPlugin_ForwardServer = grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]
// TransportPlugin_ServiceDesc is the grpc.ServiceDesc for TransportPlugin service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var TransportPlugin_ServiceDesc = grpc.ServiceDesc{
ServiceName: "sub2api.plugin.v1.TransportPlugin",
HandlerType: (*TransportPluginServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetInfo",
Handler: _TransportPlugin_GetInfo_Handler,
},
{
MethodName: "Health",
Handler: _TransportPlugin_Health_Handler,
},
{
MethodName: "ValidateConfig",
Handler: _TransportPlugin_ValidateConfig_Handler,
},
{
MethodName: "ApplyConfig",
Handler: _TransportPlugin_ApplyConfig_Handler,
},
{
MethodName: "TestConfig",
Handler: _TransportPlugin_TestConfig_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "Forward",
Handler: _TransportPlugin_Forward_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "plugin.proto",
}
+59
View File
@@ -0,0 +1,59 @@
package pluginv1
import (
"context"
hcplugin "github.com/hashicorp/go-plugin"
"google.golang.org/grpc"
)
const (
// ProtocolVersion 是宿主与插件进程握手协议版本。
ProtocolVersion = 1
// TransportAPIVersion 是 OpenAI OAuth 出站传输契约版本。
TransportAPIVersion = 1
// UIBridgeVersion 是插件管理页与沙箱 UI 的消息协议版本。
UIBridgeVersion = 1
// TransportPluginName 是 go-plugin 中注册的唯一能力名称。
TransportPluginName = "oauth_transport"
)
// HandshakeConfig 防止普通可执行文件被误当成 Sub2API 插件启动。
var HandshakeConfig = hcplugin.HandshakeConfig{
ProtocolVersion: ProtocolVersion,
MagicCookieKey: "SUB2API_PLUGIN_MAGIC_COOKIE",
MagicCookieValue: "sub2api-plugin-v1",
}
// GRPCPlugin 把生成的 gRPC 服务注册到 go-plugin 子进程。
type GRPCPlugin struct {
hcplugin.NetRPCUnsupportedPlugin
Impl TransportPluginServer
}
func (p *GRPCPlugin) GRPCServer(_ *hcplugin.GRPCBroker, server *grpc.Server) error {
RegisterTransportPluginServer(server, p.Impl)
return nil
}
func (p *GRPCPlugin) GRPCClient(_ context.Context, _ *hcplugin.GRPCBroker, conn *grpc.ClientConn) (any, error) {
return NewTransportPluginClient(conn), nil
}
// ClientPluginMap 返回宿主侧使用的插件声明。
func ClientPluginMap() map[string]hcplugin.Plugin {
return map[string]hcplugin.Plugin{
TransportPluginName: &GRPCPlugin{},
}
}
// Serve 启动一个实现了传输协议的插件进程。
func Serve(impl TransportPluginServer) {
hcplugin.Serve(&hcplugin.ServeConfig{
HandshakeConfig: HandshakeConfig,
Plugins: map[string]hcplugin.Plugin{
TransportPluginName: &GRPCPlugin{Impl: impl},
},
GRPCServer: hcplugin.DefaultGRPCServer,
})
}