Files
sub2api/backend/internal/service/plugin_runtime.go
T
shaw c63bd14a02 feat(plugin): generic host services + read-only status bridge channel
Extend the local plugin system's host<->plugin contract with generic,
capability-neutral facilities, so transport plugins can be built on top
without embedding plugin-specific logic in the host.

Host services (plugin dials a broker-provided HostService gRPC):
- per-plugin namespaced KV store (key/value size and TTL bounded)
- account directory: ListAccounts + ResolveOutboundIdentity, restricted to
  the accounts a plugin's declared capability already covers
- InitHostServices lifecycle hook; plugins that don't implement it run
  unchanged and are simply offered no host services

Read-only runtime status channel:
- HealthResponse gains an opaque, plugin-defined status_json produced
  passively (no config apply, no upstream call, no side effects)
- GET /admin/plugins/:id/status (ungated) and the plugin.status UI-bridge
  verb map to Health, so a status UI can poll live state without a step-up
  prompt or a "test" side effect
- config.test no longer force-shows a host success toast; the plugin UI owns
  result presentation, so plugins that read status do not spam toasts

Document the UI-bridge verb taxonomy (read vs mutate/test gating) and the
status contract under pkg/pluginapi.
2026-09-19 11:25:56 +08:00

488 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os/exec"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
hclog "github.com/hashicorp/go-hclog"
hcplugin "github.com/hashicorp/go-plugin"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type pluginRuntime struct {
installation *PluginInstallation
client *hcplugin.Client
api pluginv1.TransportPluginClient
inFlight atomic.Int64
draining atomic.Bool
done chan struct{}
doneOnce sync.Once
}
func startPluginRuntime(ctx context.Context, installation *PluginInstallation, startTimeout time.Duration, socketDir string, hostServices pluginv1.HostServiceServer) (*pluginRuntime, error) {
if installation == nil {
return nil, errors.New("插件安装记录为空")
}
checksum, err := hex.DecodeString(installation.BinarySHA256)
if err != nil || len(checksum) != sha256.Size {
return nil, errors.New("插件二进制哈希无效")
}
cmd := exec.CommandContext(context.WithoutCancel(ctx), installation.BinaryPath)
client := hcplugin.NewClient(&hcplugin.ClientConfig{
HandshakeConfig: pluginv1.HandshakeConfig,
Plugins: pluginv1.ClientPluginMap(),
Cmd: cmd,
AllowedProtocols: []hcplugin.Protocol{hcplugin.ProtocolGRPC},
StartTimeout: startTimeout,
SecureConfig: &hcplugin.SecureConfig{
Checksum: checksum,
Hash: sha256.New(),
},
Logger: hclog.NewNullLogger(),
SyncStdout: io.Discard,
SyncStderr: io.Discard,
UnixSocketConfig: &hcplugin.UnixSocketConfig{TempDir: socketDir},
SkipHostEnv: true,
})
rpcClient, err := client.Client()
if err != nil {
client.Kill()
return nil, fmt.Errorf("启动插件进程: %w", err)
}
dispensed, err := rpcClient.Dispense(pluginv1.TransportPluginName)
if err != nil {
client.Kill()
return nil, fmt.Errorf("获取插件传输能力: %w", err)
}
transportClient, ok := dispensed.(*pluginv1.TransportClient)
if !ok || transportClient.TransportPluginClient == nil {
client.Kill()
return nil, errors.New("插件未实现传输 gRPC 客户端")
}
api := transportClient.TransportPluginClient
runtime := &pluginRuntime{
installation: installation,
client: client,
api: api,
done: make(chan struct{}),
}
infoCtx, cancel := context.WithTimeout(ctx, startTimeout)
defer cancel()
info, err := api.GetInfo(infoCtx, &pluginv1.GetInfoRequest{})
if err != nil {
runtime.kill()
return nil, fmt.Errorf("读取插件信息: %w", err)
}
if info.PluginId != installation.PluginKey || info.PluginVersion != installation.Version ||
info.ProtocolVersion != pluginv1.ProtocolVersion || info.TransportApiVersion != pluginv1.TransportAPIVersion {
runtime.kill()
return nil, errors.New("插件运行时信息与已校验清单不一致")
}
health, err := api.Health(infoCtx, &pluginv1.HealthRequest{})
if err != nil || !health.Healthy {
runtime.kill()
if err != nil {
return nil, fmt.Errorf("插件健康检查失败: %w", err)
}
return nil, fmt.Errorf("插件不健康: %s", health.Message)
}
// 可选地把宿主服务(HostService)反向暴露给插件。这是叠加在传输契约之上的能力:
// 老插件不实现 InitHostServices(返回 Unimplemented),此处静默跳过,绝不阻断启动。
offerPluginHostServices(ctx, installation, api, transportClient.Broker, hostServices, startTimeout)
return runtime, nil
}
// offerPluginHostServices 在 go-plugin broker 上启动一个宿主服务实例,并通过
// InitHostServices 把 broker 流 id 交给插件。服务生命周期与插件进程绑定:client.Kill()
// 会关闭 broker,AcceptAndServe 随之 GracefulStop,无需手动清理。整个过程尽力而为,
// 任何失败都只记录日志、不影响插件转发能力。
func offerPluginHostServices(
ctx context.Context,
installation *PluginInstallation,
api pluginv1.TransportPluginClient,
broker *hcplugin.GRPCBroker,
hostServices pluginv1.HostServiceServer,
startTimeout time.Duration,
) {
if broker == nil || hostServices == nil || api == nil {
return
}
brokerID := broker.NextId()
go broker.AcceptAndServe(brokerID, func(opts []grpc.ServerOption) *grpc.Server {
server := grpc.NewServer(opts...)
pluginv1.RegisterHostServiceServer(server, hostServices)
return server
})
initCtx, cancel := context.WithTimeout(ctx, startTimeout)
defer cancel()
resp, err := api.InitHostServices(initCtx, &pluginv1.InitHostServicesRequest{
HostServiceId: brokerID,
HostServiceApiVersion: pluginv1.HostServiceAPIVersion,
})
pluginKey := ""
if installation != nil {
pluginKey = installation.PluginKey
}
if err != nil {
if status.Code(err) == codes.Unimplemented {
slog.Debug("plugin_host_services_unimplemented", "plugin", pluginKey)
} else {
slog.Warn("plugin_host_services_init_failed", "plugin", pluginKey, "error", err)
}
return
}
if resp != nil && !resp.Ready {
slog.Debug("plugin_host_services_declined", "plugin", pluginKey, "message", resp.Message)
}
}
func (r *pluginRuntime) validateAndApplyConfig(ctx context.Context, configJSON []byte) error {
_, err := r.validateAndApplyNormalizedConfig(ctx, configJSON)
return err
}
func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, configJSON []byte) ([]byte, error) {
validation, err := r.api.ValidateConfig(ctx, &pluginv1.ValidateConfigRequest{ConfigJson: configJSON})
if err != nil {
return nil, fmt.Errorf("插件配置校验失败: %w", err)
}
if !validation.Valid {
return nil, fmt.Errorf("插件配置无效: %s", validation.Message)
}
if len(validation.NormalizedConfigJson) > 0 {
configJSON = validation.NormalizedConfigJson
}
if len(configJSON) == 0 || len(configJSON) > pluginConfigMaxBytes || !json.Valid(configJSON) {
return nil, errors.New("插件返回的规范化配置不是有效且大小受限的 JSON")
}
var normalized any
if err := json.Unmarshal(configJSON, &normalized); err != nil {
return nil, fmt.Errorf("解析插件规范化配置: %w", err)
}
if normalized == nil {
return nil, errors.New("插件返回的规范化配置根节点必须是对象")
}
if _, ok := normalized.(map[string]any); !ok {
return nil, errors.New("插件返回的规范化配置根节点必须是对象")
}
configJSON, err = json.Marshal(normalized)
if err != nil {
return nil, fmt.Errorf("序列化插件规范化配置: %w", err)
}
applied, err := r.api.ApplyConfig(ctx, &pluginv1.ApplyConfigRequest{ConfigJson: configJSON})
if err != nil {
return nil, fmt.Errorf("应用插件配置失败: %w", err)
}
if !applied.Applied {
return nil, fmt.Errorf("插件拒绝应用配置: %s", applied.Message)
}
return configJSON, nil
}
func (r *pluginRuntime) checkHealth(ctx context.Context) error {
if r == nil || r.api == nil || r.client == nil || r.client.Exited() {
return errors.New("插件进程已退出")
}
health, err := r.api.Health(ctx, &pluginv1.HealthRequest{})
if err != nil {
return fmt.Errorf("插件健康检查失败: %w", err)
}
if health == nil || !health.Healthy {
message := "插件报告不健康"
if health != nil && strings.TrimSpace(health.Message) != "" {
message = "插件不健康: " + health.Message
}
return errors.New(message)
}
return nil
}
// status returns the plugin's passive Health response, including any status_json
// blob it exposes for the config UI. It performs no config apply and no upstream
// call, so it is safe to serve from a lightweight, ungated status endpoint.
func (r *pluginRuntime) status(ctx context.Context) (*pluginv1.HealthResponse, error) {
if r == nil || r.api == nil || r.client == nil || r.client.Exited() {
return nil, errors.New("插件进程已退出")
}
health, err := r.api.Health(ctx, &pluginv1.HealthRequest{})
if err != nil {
return nil, fmt.Errorf("插件状态查询失败: %w", err)
}
if health == nil {
return nil, errors.New("插件未返回状态")
}
return health, nil
}
func (r *pluginRuntime) beginRequest() bool {
if r == nil || r.draining.Load() {
return false
}
r.inFlight.Add(1)
if r.draining.Load() {
r.finishRequest()
return false
}
return true
}
func (r *pluginRuntime) finishRequest() {
if r.inFlight.Add(-1) == 0 && r.draining.Load() {
r.doneOnce.Do(func() { close(r.done) })
}
}
func (r *pluginRuntime) drain(timeout time.Duration) {
if r == nil {
return
}
r.draining.Store(true)
if r.inFlight.Load() == 0 {
r.doneOnce.Do(func() { close(r.done) })
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-r.done:
case <-timer.C:
}
r.kill()
}
func (r *pluginRuntime) kill() {
if r != nil && r.client != nil {
r.client.Kill()
}
}
func (r *pluginRuntime) roundTrip(ctx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, error) {
if request == nil || request.URL == nil || account == nil {
return nil, errors.New("插件出站请求参数不完整")
}
streamCtx, cancel := context.WithCancel(ctx)
stream, err := r.api.Forward(streamCtx)
if err != nil {
cancel()
return nil, normalizePluginRPCError(ctx, "创建插件转发流", err, false)
}
requestID := strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.FormatInt(account.ID, 36)
if err := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_Start{Start: &pluginv1.ForwardRequestStart{
RequestId: requestID,
Method: request.Method,
Url: request.URL.String(),
Host: request.Host,
Headers: headersToPlugin(request.Header),
ProxyUrl: proxyURL,
AccountId: account.ID,
AccountConcurrency: int32(account.Concurrency),
Platform: account.Platform,
AccountType: account.Type,
ContentLength: request.ContentLength,
HasBody: request.Body != nil && request.Body != http.NoBody,
}}}); err != nil {
cancel()
// gRPC Send 返回错误时无法证明服务端没有收到元数据,必须禁止自动重放。
return nil, normalizePluginRPCError(ctx, "发送插件请求元数据", err, true)
}
sendErr := make(chan error, 1)
go func() {
err := sendPluginRequestBody(stream, request.Body)
sendErr <- err
if err != nil {
cancel()
}
}()
first, err := stream.Recv()
if err != nil {
cancel()
select {
case bodyErr := <-sendErr:
if bodyErr != nil {
return nil, normalizePluginRPCError(ctx, "发送插件请求体", bodyErr, true)
}
default:
}
return nil, normalizePluginRPCError(ctx, "接收插件响应头", err, true)
}
if frameError := first.GetError(); frameError != nil {
cancel()
return nil, &PluginTransportError{Code: frameError.Code, Message: frameError.Message, RequestSent: frameError.RequestSent}
}
start := first.GetStart()
if start == nil || start.StatusCode < 100 || start.StatusCode > 599 {
cancel()
return nil, &PluginTransportError{
Code: "PLUGIN_INVALID_RESPONSE",
Message: "插件未返回有效的 HTTP 响应头",
RequestSent: true,
}
}
pipeReader, pipeWriter := io.Pipe()
body := &pluginResponseBody{
reader: pipeReader,
cancel: cancel,
done: r.finishRequest,
}
go receivePluginResponseBody(stream, pipeWriter, sendErr)
return &http.Response{
Status: start.Status,
StatusCode: int(start.StatusCode),
Proto: start.Protocol,
ProtoMajor: int(start.ProtocolMajor),
ProtoMinor: int(start.ProtocolMinor),
Header: headersFromPlugin(start.Headers),
Body: body,
ContentLength: start.ContentLength,
Request: request,
}, nil
}
type PluginTransportError struct {
Code string
Message string
RequestSent bool
}
func (e *PluginTransportError) Error() string {
if e == nil {
return "插件传输失败"
}
code := strings.Map(func(value rune) rune {
if value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' || value == '_' || value == '-' || value == '.' {
return value
}
return -1
}, e.Code)
if len(code) > 64 {
code = code[:64]
}
return fmt.Sprintf("插件传输失败 [%s]: %s", code, sanitizeUpstreamErrorMessage(e.Message))
}
func normalizePluginRPCError(ctx context.Context, operation string, err error, requestMayHaveBeenSent bool) error {
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
return &PluginTransportError{
Code: "PLUGIN_RPC_ERROR",
Message: fmt.Sprintf("%s: %v", operation, err),
RequestSent: requestMayHaveBeenSent,
}
}
func sendPluginRequestBody(stream pluginv1.TransportPlugin_ForwardClient, body io.ReadCloser) error {
if body != nil {
defer func() { _ = body.Close() }()
buffer := make([]byte, 32*1024)
for {
read, err := body.Read(buffer)
if read > 0 {
chunk := append([]byte(nil), buffer[:read]...)
if sendErr := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_BodyChunk{BodyChunk: chunk}}); sendErr != nil {
return sendErr
}
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
}
if err := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_BodyEnd{BodyEnd: true}}); err != nil {
return err
}
return stream.CloseSend()
}
func receivePluginResponseBody(stream pluginv1.TransportPlugin_ForwardClient, writer *io.PipeWriter, sendErr <-chan error) {
defer func() { _ = writer.Close() }()
for {
frame, err := stream.Recv()
if err == io.EOF {
return
}
if err != nil {
_ = writer.CloseWithError(normalizePluginRPCError(stream.Context(), "接收插件响应体", err, true))
return
}
if chunk := frame.GetBodyChunk(); len(chunk) > 0 {
if _, err := writer.Write(chunk); err != nil {
return
}
continue
}
if frame.GetEnd() != nil {
select {
case err := <-sendErr:
if err != nil {
_ = writer.CloseWithError(normalizePluginRPCError(stream.Context(), "发送插件请求体", err, true))
}
default:
}
return
}
if frameError := frame.GetError(); frameError != nil {
_ = writer.CloseWithError(&PluginTransportError{Code: frameError.Code, Message: frameError.Message, RequestSent: frameError.RequestSent})
return
}
}
}
type pluginResponseBody struct {
reader *io.PipeReader
cancel context.CancelFunc
done func()
once sync.Once
}
func (b *pluginResponseBody) Read(data []byte) (int, error) {
return b.reader.Read(data)
}
func (b *pluginResponseBody) Close() error {
var err error
b.once.Do(func() {
b.cancel()
err = b.reader.Close()
b.done()
})
return err
}
func headersToPlugin(headers http.Header) map[string]*pluginv1.HeaderValues {
out := make(map[string]*pluginv1.HeaderValues, len(headers))
for key, values := range headers {
out[key] = &pluginv1.HeaderValues{Values: append([]string(nil), values...)}
}
return out
}
func headersFromPlugin(headers map[string]*pluginv1.HeaderValues) http.Header {
out := make(http.Header, len(headers))
for key, values := range headers {
if values != nil {
out[key] = append([]string(nil), values.Values...)
}
}
return out
}