| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396 |
- 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)
- }
- archiveClosed := false
- closeArchive := func() {
- if !archiveClosed {
- archiveClosed = true
- _ = archive.Close()
- }
- }
- defer closeArchive()
- 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
- }
- // Windows 不允许重命名仍被打开的文件,提交前先释放 ZIP 读取器。
- closeArchive()
- 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
- }
|