388 lines
13 KiB
Go
388 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
)
|
|
|
|
const (
|
|
pluginManifestFilename = "manifest.json"
|
|
pluginSignatureFilename = "signature.json"
|
|
pluginArchiveMaxFiles = 512
|
|
builtInOpenAITransportPluginID = "local.sub2api.openai-transport"
|
|
builtInOpenAITransportPublisherKeyID = "sub2api-openai-transport-v1"
|
|
builtInOpenAITransportPublisherKeyBase64 = "MqzSXAoG0iVR5kKWrC+mqcCeExkrT6zAr2WpQ4sA+yc="
|
|
)
|
|
|
|
type PluginPackageInstaller struct {
|
|
cfg *config.Config
|
|
hostInfo PluginHostInfo
|
|
rootDir string
|
|
}
|
|
|
|
func NewPluginPackageInstaller(cfg *config.Config, hostInfo PluginHostInfo) *PluginPackageInstaller {
|
|
return &PluginPackageInstaller{
|
|
cfg: cfg,
|
|
hostInfo: hostInfo,
|
|
rootDir: resolvePluginRootDir(cfg),
|
|
}
|
|
}
|
|
|
|
func resolvePluginRootDir(cfg *config.Config) string {
|
|
if cfg != nil && strings.TrimSpace(cfg.Plugins.DataDir) != "" {
|
|
return filepath.Clean(cfg.Plugins.DataDir)
|
|
}
|
|
base := strings.TrimSpace(os.Getenv("DATA_DIR"))
|
|
if base == "" {
|
|
base = "./data"
|
|
}
|
|
return filepath.Join(base, "plugins")
|
|
}
|
|
|
|
func (i *PluginPackageInstaller) RootDir() string {
|
|
return i.rootDir
|
|
}
|
|
|
|
func (i *PluginPackageInstaller) Install(ctx context.Context, reader io.Reader, installedBy *int64) (*PluginInstallation, error) {
|
|
if i == nil || i.cfg == nil {
|
|
return nil, errors.New("插件安装器未配置")
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
stagingDir := filepath.Join(i.rootDir, "staging")
|
|
packagesDir := filepath.Join(i.rootDir, "packages")
|
|
installedDir := filepath.Join(i.rootDir, "installed")
|
|
for _, dir := range []string{stagingDir, packagesDir, installedDir} {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("创建插件目录: %w", err)
|
|
}
|
|
}
|
|
|
|
tempFile, err := os.CreateTemp(stagingDir, "upload-*.s2plugin")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("创建插件上传临时文件: %w", err)
|
|
}
|
|
tempPath := tempFile.Name()
|
|
committed := false
|
|
defer func() {
|
|
_ = tempFile.Close()
|
|
if !committed {
|
|
_ = os.Remove(tempPath)
|
|
}
|
|
}()
|
|
|
|
hasher := sha256.New()
|
|
limit := i.cfg.Plugins.MaxUploadBytes
|
|
written, err := io.Copy(io.MultiWriter(tempFile, hasher), io.LimitReader(reader, limit+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("读取插件包: %w", err)
|
|
}
|
|
if written > limit {
|
|
return nil, fmt.Errorf("插件包超过 %d 字节限制", limit)
|
|
}
|
|
if err := tempFile.Sync(); err != nil {
|
|
return nil, fmt.Errorf("同步插件包: %w", err)
|
|
}
|
|
if err := tempFile.Close(); err != nil {
|
|
return nil, fmt.Errorf("关闭插件包: %w", err)
|
|
}
|
|
artifactSHA := hex.EncodeToString(hasher.Sum(nil))
|
|
|
|
archive, err := zip.OpenReader(tempPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("插件包不是有效的 ZIP: %w", err)
|
|
}
|
|
defer func() { _ = archive.Close() }()
|
|
manifest, _, signatureStatus, err := i.inspectArchive(&archive.Reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
compatibility := EvaluatePluginCompatibility(manifest, i.hostInfo)
|
|
initialState := PluginStateDisabled
|
|
if !compatibility.Compatible {
|
|
initialState = PluginStateIncompatible
|
|
}
|
|
|
|
installParent := filepath.Join(installedDir, manifest.ID)
|
|
if err := os.MkdirAll(installParent, 0o700); err != nil {
|
|
return nil, fmt.Errorf("创建插件安装父目录: %w", err)
|
|
}
|
|
extractPath, err := os.MkdirTemp(installParent, ".install-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("创建插件安装临时目录: %w", err)
|
|
}
|
|
installNonce := strings.TrimPrefix(filepath.Base(extractPath), ".install-")
|
|
installPath := filepath.Join(installParent, manifest.Version+"-"+artifactSHA[:12]+"-"+installNonce)
|
|
extracted := false
|
|
defer func() {
|
|
if !extracted {
|
|
_ = os.RemoveAll(extractPath)
|
|
}
|
|
}()
|
|
if err := i.extractArchive(ctx, &archive.Reader, manifest, extractPath); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.Rename(extractPath, installPath); err != nil {
|
|
return nil, fmt.Errorf("提交插件安装目录: %w", err)
|
|
}
|
|
extracted = true
|
|
|
|
artifactPath := filepath.Join(packagesDir, manifest.ID+"-"+manifest.Version+"-"+artifactSHA[:12]+"-"+installNonce+".s2plugin")
|
|
if err := os.Rename(tempPath, artifactPath); err != nil {
|
|
_ = os.RemoveAll(installPath)
|
|
return nil, fmt.Errorf("保存插件包: %w", err)
|
|
}
|
|
committed = true
|
|
artifactData, err := os.ReadFile(artifactPath)
|
|
if err != nil {
|
|
_ = os.Remove(artifactPath)
|
|
_ = os.RemoveAll(installPath)
|
|
return nil, fmt.Errorf("读取已保存插件包: %w", err)
|
|
}
|
|
runtimeEntry := manifest.Runtimes[manifest.RuntimeKey()]
|
|
return &PluginInstallation{
|
|
PluginKey: manifest.ID,
|
|
Name: manifest.Name,
|
|
Version: manifest.Version,
|
|
Description: manifest.Description,
|
|
Author: manifest.Author,
|
|
Manifest: manifest,
|
|
ArtifactData: artifactData,
|
|
ArtifactPath: artifactPath,
|
|
InstallPath: installPath,
|
|
BinaryPath: filepath.Join(installPath, filepath.FromSlash(runtimeEntry.Path)),
|
|
BinarySHA256: manifest.Files[runtimeEntry.Path],
|
|
SignatureStatus: signatureStatus,
|
|
State: initialState,
|
|
InstalledBy: installedBy,
|
|
Compatibility: compatibility,
|
|
}, nil
|
|
}
|
|
|
|
func (i *PluginPackageInstaller) inspectArchive(archive *zip.Reader) (PluginManifest, []byte, string, error) {
|
|
if len(archive.File) == 0 || len(archive.File) > pluginArchiveMaxFiles {
|
|
return PluginManifest{}, nil, "", errors.New("插件包文件数量无效")
|
|
}
|
|
entries := make(map[string]*zip.File, len(archive.File))
|
|
var total uint64
|
|
for _, file := range archive.File {
|
|
if file.FileInfo().IsDir() {
|
|
if _, err := normalizePluginArchivePath(strings.TrimSuffix(file.Name, "/")); err != nil {
|
|
return PluginManifest{}, nil, "", err
|
|
}
|
|
continue
|
|
}
|
|
name, err := normalizePluginArchivePath(file.Name)
|
|
if err != nil {
|
|
return PluginManifest{}, nil, "", err
|
|
}
|
|
if _, exists := entries[name]; exists {
|
|
return PluginManifest{}, nil, "", fmt.Errorf("插件包包含重复路径: %s", name)
|
|
}
|
|
if file.Mode()&os.ModeSymlink != 0 {
|
|
return PluginManifest{}, nil, "", fmt.Errorf("插件包不允许符号链接: %s", name)
|
|
}
|
|
total += file.UncompressedSize64
|
|
if total > uint64(i.cfg.Plugins.MaxUncompressedBytes) {
|
|
return PluginManifest{}, nil, "", errors.New("插件包解压后体积超过限制")
|
|
}
|
|
entries[name] = file
|
|
}
|
|
manifestFile := entries[pluginManifestFilename]
|
|
if manifestFile == nil {
|
|
return PluginManifest{}, nil, "", errors.New("插件包缺少 manifest.json")
|
|
}
|
|
manifestRaw, err := readPluginZipFile(manifestFile, 2*1024*1024)
|
|
if err != nil {
|
|
return PluginManifest{}, nil, "", fmt.Errorf("读取插件清单: %w", err)
|
|
}
|
|
var manifest PluginManifest
|
|
decoder := json.NewDecoder(bytes.NewReader(manifestRaw))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&manifest); err != nil {
|
|
return PluginManifest{}, nil, "", fmt.Errorf("解析插件清单: %w", err)
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
return PluginManifest{}, nil, "", errors.New("插件清单只能包含一个 JSON 对象")
|
|
}
|
|
if err := manifest.Validate(); err != nil {
|
|
return PluginManifest{}, nil, "", err
|
|
}
|
|
for path := range entries {
|
|
if path == pluginManifestFilename || path == pluginSignatureFilename {
|
|
continue
|
|
}
|
|
if _, declared := manifest.Files[path]; !declared {
|
|
return PluginManifest{}, nil, "", fmt.Errorf("插件包包含未声明文件: %s", path)
|
|
}
|
|
}
|
|
for path := range manifest.Files {
|
|
if entries[path] == nil {
|
|
return PluginManifest{}, nil, "", fmt.Errorf("插件包缺少已声明文件: %s", path)
|
|
}
|
|
}
|
|
signatureStatus, err := i.verifySignature(entries[pluginSignatureFilename], manifestRaw, manifest.ID)
|
|
if err != nil {
|
|
return PluginManifest{}, nil, "", err
|
|
}
|
|
return manifest, manifestRaw, signatureStatus, nil
|
|
}
|
|
|
|
func (i *PluginPackageInstaller) verifySignature(file *zip.File, manifestRaw []byte, pluginID string) (string, error) {
|
|
if file == nil {
|
|
if i.cfg.Plugins.AllowUnsigned {
|
|
return PluginSignatureUnsigned, nil
|
|
}
|
|
return "", errors.New("生产配置不允许安装未签名插件")
|
|
}
|
|
raw, err := readPluginZipFile(file, 64*1024)
|
|
if err != nil {
|
|
return "", fmt.Errorf("读取插件签名: %w", err)
|
|
}
|
|
var signature PluginSignature
|
|
if err := json.Unmarshal(raw, &signature); err != nil {
|
|
return "", fmt.Errorf("解析插件签名: %w", err)
|
|
}
|
|
if signature.Algorithm != "ed25519" || strings.TrimSpace(signature.KeyID) == "" {
|
|
return "", errors.New("插件签名算法或密钥 ID 无效")
|
|
}
|
|
encodedKey := trustedPluginPublisherKey(i.cfg, signature.KeyID, pluginID)
|
|
if encodedKey == "" {
|
|
return "", fmt.Errorf("插件发布者密钥不受信任: %s", signature.KeyID)
|
|
}
|
|
publicKey, err := base64.StdEncoding.DecodeString(encodedKey)
|
|
if err != nil || len(publicKey) != ed25519.PublicKeySize {
|
|
return "", fmt.Errorf("受信任发布者密钥无效: %s", signature.KeyID)
|
|
}
|
|
signatureBytes, err := base64.StdEncoding.DecodeString(signature.Signature)
|
|
if err != nil || !ed25519.Verify(ed25519.PublicKey(publicKey), manifestRaw, signatureBytes) {
|
|
return "", errors.New("插件签名校验失败")
|
|
}
|
|
return PluginSignatureTrusted, nil
|
|
}
|
|
|
|
func trustedPluginPublisherKey(cfg *config.Config, keyID, pluginID string) string {
|
|
// 内置公钥是官方私有插件的固定信任根,不允许被部署配置覆盖。
|
|
if keyID == builtInOpenAITransportPublisherKeyID {
|
|
if pluginID != builtInOpenAITransportPluginID {
|
|
return ""
|
|
}
|
|
return builtInOpenAITransportPublisherKeyBase64
|
|
}
|
|
if cfg == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(cfg.Plugins.TrustedPublishers[keyID])
|
|
}
|
|
|
|
func (i *PluginPackageInstaller) extractArchive(ctx context.Context, archive *zip.Reader, manifest PluginManifest, target string) error {
|
|
var extractedBytes int64
|
|
extractLimit := i.cfg.Plugins.MaxUncompressedBytes
|
|
for path, expectedHash := range manifest.Files {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
var source *zip.File
|
|
for _, file := range archive.File {
|
|
if strings.ReplaceAll(file.Name, "\\", "/") == path {
|
|
source = file
|
|
break
|
|
}
|
|
}
|
|
if source == nil {
|
|
return fmt.Errorf("缺少插件文件: %s", path)
|
|
}
|
|
destination, err := safePluginJoin(target, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
|
|
return fmt.Errorf("创建插件文件目录: %w", err)
|
|
}
|
|
input, err := source.Open()
|
|
if err != nil {
|
|
return fmt.Errorf("打开插件文件 %s: %w", path, err)
|
|
}
|
|
hasher := sha256.New()
|
|
mode := os.FileMode(0o600)
|
|
if path == manifest.Runtimes[manifest.RuntimeKey()].Path {
|
|
mode = 0o700
|
|
}
|
|
output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
|
|
if err != nil {
|
|
_ = input.Close()
|
|
return fmt.Errorf("创建插件文件 %s: %w", path, err)
|
|
}
|
|
remaining := extractLimit - extractedBytes
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
copied, copyErr := io.Copy(io.MultiWriter(output, hasher), io.LimitReader(input, remaining+1))
|
|
extractedBytes += copied
|
|
closeOutErr := output.Close()
|
|
closeInErr := input.Close()
|
|
if copyErr != nil || closeOutErr != nil || closeInErr != nil {
|
|
return fmt.Errorf("解压插件文件 %s 失败", path)
|
|
}
|
|
if extractedBytes > extractLimit {
|
|
return errors.New("插件包实际解压体积超过限制")
|
|
}
|
|
if actual := hex.EncodeToString(hasher.Sum(nil)); actual != expectedHash {
|
|
return fmt.Errorf("插件文件哈希不匹配: %s", path)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizePluginArchivePath(name string) (string, error) {
|
|
normalized := strings.ReplaceAll(strings.TrimSpace(name), "\\", "/")
|
|
if normalized == "" || strings.HasPrefix(normalized, "/") || strings.Contains(normalized, "\x00") {
|
|
return "", errors.New("插件包包含无效路径")
|
|
}
|
|
cleaned := filepath.ToSlash(filepath.Clean(normalized))
|
|
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || cleaned != normalized {
|
|
return "", fmt.Errorf("插件包包含不安全路径: %s", name)
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
func safePluginJoin(root, relative string) (string, error) {
|
|
destination := filepath.Join(root, filepath.FromSlash(relative))
|
|
rel, err := filepath.Rel(root, destination)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("插件路径越界: %s", relative)
|
|
}
|
|
return destination, nil
|
|
}
|
|
|
|
func readPluginZipFile(file *zip.File, limit int64) ([]byte, error) {
|
|
reader, err := file.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
data, err := io.ReadAll(io.LimitReader(reader, limit+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(data)) > limit {
|
|
return nil, errors.New("插件文件超过读取限制")
|
|
}
|
|
return data, nil
|
|
}
|