From 42e11e0aca4be0f298cf125d1760e32130809501 Mon Sep 17 00:00:00 2001 From: Flik Date: Tue, 30 Dec 2025 22:06:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E5=AE=9E=E7=8E=B0=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=AE=89=E5=85=A8=E9=AA=8C=E8=AF=81=E5=92=8C=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E6=97=A5=E5=BF=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加插件签名验证机制,支持远程证书吊销列表 - 增加插件安装时的安全检查和签名验证 - 实现插件版本存储的HMAC完整性校验 - 添加插件审计日志记录插件安装和验证事件 - 增加JS插件沙箱安全限制配置 - 添加插件商店API的签名URL字段支持 - 实现安全配置的自动刷新机制 --- cmd/client/main.go | 40 +++++ cmd/server/main.go | 32 ++++ internal/client/tunnel/version_store.go | 67 ++++++++- internal/server/config/config.go | 6 + internal/server/router/api.go | 49 +++++-- pkg/plugin/audit/audit.go | 154 ++++++++++++++++++++ pkg/plugin/script/js.go | 5 + pkg/plugin/script/sandbox.go | 10 +- pkg/plugin/sign/official.go | 149 ++++++++++++++++++- pkg/plugin/sign/revocation.go | 186 +++++++++++++++++++++++- web/src/api/index.ts | 6 +- web/src/types/index.ts | 1 + web/src/views/PluginsView.vue | 12 +- 13 files changed, 686 insertions(+), 31 deletions(-) create mode 100644 pkg/plugin/audit/audit.go diff --git a/cmd/client/main.go b/cmd/client/main.go index f0c4c32..418e746 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -5,11 +5,13 @@ import ( "log" "os" "path/filepath" + "time" "github.com/gotunnel/internal/client/tunnel" "github.com/gotunnel/pkg/crypto" "github.com/gotunnel/pkg/plugin" "github.com/gotunnel/pkg/plugin/builtin" + "github.com/gotunnel/pkg/plugin/sign" ) func main() { @@ -40,6 +42,9 @@ func main() { } } + // 初始化安全配置 + initSecurityConfig() + // 初始化插件系统 registry := plugin.NewRegistry() for _, h := range builtin.GetClientPlugins() { @@ -52,3 +57,38 @@ func main() { client.Run() } + +// 官方安全配置 URL(与服务端保持一致) +const ( + officialRevocationURL = "https://git.92coco.cn:8443/flik/GoTunnel-Plugins/raw/branch/main/security/revocation.json" + officialKeyListURL = "https://git.92coco.cn:8443/flik/GoTunnel-Plugins/raw/branch/main/security/keys.json" +) + +// initSecurityConfig 初始化安全配置 +func initSecurityConfig() { + // 配置撤销列表 + sign.SetRevocationConfig(sign.RevocationConfig{ + RemoteURL: officialRevocationURL, + FetchInterval: 1 * time.Hour, + RequestTimeout: 10 * time.Second, + VerifySignature: true, + }) + + // 配置公钥列表 + sign.SetKeyListConfig(sign.KeyListConfig{ + RemoteURL: officialKeyListURL, + FetchInterval: 24 * time.Hour, + RequestTimeout: 10 * time.Second, + }) + + // 启动后台刷新 + stopCh := make(chan struct{}) + go sign.StartRevocationRefresher(stopCh) + + // 立即拉取一次 + if err := sign.FetchRemoteKeyList(); err != nil { + log.Printf("[Security] Fetch key list failed: %v", err) + } + + log.Printf("[Security] Initialized") +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 2606c39..f65b04f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -59,6 +59,9 @@ func main() { log.Printf("[Server] TLS enabled") } + // 初始化安全配置(撤销列表和公钥列表) + initSecurityConfig() + // 初始化插件系统 registry := plugin.NewRegistry() if err := registry.RegisterAllServer(builtin.GetServerPlugins()); err != nil { @@ -188,3 +191,32 @@ func verifyPluginSignature(name, source, signature string) error { // 验证签名 return sign.VerifyPlugin(pubKey, signed, source) } + +// initSecurityConfig 初始化安全配置 +func initSecurityConfig() { + // 配置撤销列表 + sign.SetRevocationConfig(sign.RevocationConfig{ + RemoteURL: config.OfficialRevocationURL, + FetchInterval: 1 * time.Hour, + RequestTimeout: 10 * time.Second, + VerifySignature: true, + }) + + // 配置公钥列表 + sign.SetKeyListConfig(sign.KeyListConfig{ + RemoteURL: config.OfficialKeyListURL, + FetchInterval: 24 * time.Hour, + RequestTimeout: 10 * time.Second, + }) + + // 启动后台刷新 + stopCh := make(chan struct{}) + go sign.StartRevocationRefresher(stopCh) + + // 立即拉取一次公钥列表 + if err := sign.FetchRemoteKeyList(); err != nil { + log.Printf("[Security] Fetch key list failed: %v", err) + } + + log.Printf("[Security] Initialized with remote revocation and key list") +} diff --git a/internal/client/tunnel/version_store.go b/internal/client/tunnel/version_store.go index 5579ff9..0407417 100644 --- a/internal/client/tunnel/version_store.go +++ b/internal/client/tunnel/version_store.go @@ -1,15 +1,26 @@ package tunnel import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" + "fmt" "os" "path/filepath" "sync" ) +// versionStoreData 版本存储数据结构(带 HMAC) +type versionStoreData struct { + Versions map[string]string `json:"versions"` + HMAC string `json:"hmac"` +} + // PluginVersionStore 插件版本存储 type PluginVersionStore struct { path string + hmacKey []byte versions map[string]string // pluginName -> version mu sync.RWMutex } @@ -18,6 +29,7 @@ type PluginVersionStore struct { func NewPluginVersionStore(dataDir string) (*PluginVersionStore, error) { store := &PluginVersionStore{ path: filepath.Join(dataDir, "plugin_versions.json"), + hmacKey: deriveHMACKey(dataDir), versions: make(map[string]string), } if err := store.load(); err != nil { @@ -26,6 +38,15 @@ func NewPluginVersionStore(dataDir string) (*PluginVersionStore, error) { return store, nil } +// deriveHMACKey 从数据目录派生 HMAC 密钥 +func deriveHMACKey(dataDir string) []byte { + // 使用数据目录路径和机器特征派生密钥 + hostname, _ := os.Hostname() + seed := fmt.Sprintf("gotunnel-version-store:%s:%s", dataDir, hostname) + hash := sha256.Sum256([]byte(seed)) + return hash[:] +} + // load 从文件加载版本信息 func (s *PluginVersionStore) load() error { data, err := os.ReadFile(s.path) @@ -35,7 +56,26 @@ func (s *PluginVersionStore) load() error { if err != nil { return err } - return json.Unmarshal(data, &s.versions) + + var storeData versionStoreData + if err := json.Unmarshal(data, &storeData); err != nil { + // 尝试兼容旧格式(无 HMAC) + if err := json.Unmarshal(data, &s.versions); err != nil { + return fmt.Errorf("invalid version store format: %w", err) + } + // 迁移到新格式 + return s.save() + } + + // 验证 HMAC + if !s.verifyHMAC(storeData.Versions, storeData.HMAC) { + // HMAC 验证失败,可能被篡改,重置版本信息 + s.versions = make(map[string]string) + return fmt.Errorf("version store integrity check failed, data may be tampered") + } + + s.versions = storeData.Versions + return nil } // save 保存版本信息到文件 @@ -44,7 +84,16 @@ func (s *PluginVersionStore) save() error { if err := os.MkdirAll(dir, 0755); err != nil { return err } - data, err := json.MarshalIndent(s.versions, "", " ") + + // 计算 HMAC + hmacValue := s.computeHMAC(s.versions) + + storeData := versionStoreData{ + Versions: s.versions, + HMAC: hmacValue, + } + + data, err := json.MarshalIndent(storeData, "", " ") if err != nil { return err } @@ -65,3 +114,17 @@ func (s *PluginVersionStore) SetVersion(name, version string) error { s.versions[name] = version return s.save() } + +// computeHMAC 计算版本数据的 HMAC +func (s *PluginVersionStore) computeHMAC(versions map[string]string) string { + data, _ := json.Marshal(versions) + h := hmac.New(sha256.New, s.hmacKey) + h.Write(data) + return hex.EncodeToString(h.Sum(nil)) +} + +// verifyHMAC 验证 HMAC +func (s *PluginVersionStore) verifyHMAC(versions map[string]string, expectedHMAC string) bool { + computed := s.computeHMAC(versions) + return hmac.Equal([]byte(computed), []byte(expectedHMAC)) +} diff --git a/internal/server/config/config.go b/internal/server/config/config.go index 8239cad..db0a75c 100644 --- a/internal/server/config/config.go +++ b/internal/server/config/config.go @@ -34,6 +34,12 @@ type PluginStoreSettings struct { // 官方插件商店(不可配置) const OfficialPluginStoreURL = "https://git.92coco.cn:8443/flik/GoTunnel-Plugins/raw/branch/main/store.json" +// 官方安全配置 URL +const ( + OfficialRevocationURL = "https://git.92coco.cn:8443/flik/GoTunnel-Plugins/raw/branch/main/security/revocation.json" + OfficialKeyListURL = "https://git.92coco.cn:8443/flik/GoTunnel-Plugins/raw/branch/main/security/keys.json" +) + // ServerSettings 服务端设置 type ServerSettings struct { BindAddr string `yaml:"bind_addr"` diff --git a/internal/server/router/api.go b/internal/server/router/api.go index 8c7547b..af1bd6f 100644 --- a/internal/server/router/api.go +++ b/internal/server/router/api.go @@ -574,20 +574,22 @@ func (h *APIHandler) installPluginsToClient(rw http.ResponseWriter, r *http.Requ // StorePluginInfo 扩展商店插件信息 type StorePluginInfo struct { - Name string `json:"name"` - Version string `json:"version"` - Type string `json:"type"` - Description string `json:"description"` - Author string `json:"author"` - Icon string `json:"icon,omitempty"` - DownloadURL string `json:"download_url,omitempty"` + Name string `json:"name"` + Version string `json:"version"` + Type string `json:"type"` + Description string `json:"description"` + Author string `json:"author"` + Icon string `json:"icon,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + SignatureURL string `json:"signature_url,omitempty"` } // StorePluginInstallRequest 从商店安装插件的请求 type StorePluginInstallRequest struct { - PluginName string `json:"plugin_name"` - DownloadURL string `json:"download_url"` - ClientID string `json:"client_id"` + PluginName string `json:"plugin_name"` + DownloadURL string `json:"download_url"` + SignatureURL string `json:"signature_url"` + ClientID string `json:"client_id"` } // handleStorePlugins 处理扩展商店插件列表 @@ -628,8 +630,7 @@ func (h *APIHandler) handleStorePlugins(rw http.ResponseWriter, r *http.Request) } h.jsonResponse(rw, map[string]interface{}{ - "plugins": plugins, - "store_url": storeURL, + "plugins": plugins, }) } @@ -646,8 +647,8 @@ func (h *APIHandler) handleStoreInstall(rw http.ResponseWriter, r *http.Request) return } - if req.PluginName == "" || req.DownloadURL == "" || req.ClientID == "" { - http.Error(rw, "plugin_name, download_url and client_id required", http.StatusBadRequest) + if req.PluginName == "" || req.DownloadURL == "" || req.ClientID == "" || req.SignatureURL == "" { + http.Error(rw, "plugin_name, download_url, signature_url and client_id required", http.StatusBadRequest) return } @@ -678,10 +679,30 @@ func (h *APIHandler) handleStoreInstall(rw http.ResponseWriter, r *http.Request) return } + // 下载签名文件 + sigResp, err := client.Get(req.SignatureURL) + if err != nil { + http.Error(rw, "Failed to download signature: "+err.Error(), http.StatusBadGateway) + return + } + defer sigResp.Body.Close() + + if sigResp.StatusCode != http.StatusOK { + http.Error(rw, "Signature download failed with status: "+sigResp.Status, http.StatusBadGateway) + return + } + + signature, err := io.ReadAll(sigResp.Body) + if err != nil { + http.Error(rw, "Failed to read signature: "+err.Error(), http.StatusInternalServerError) + return + } + // 安装到客户端 installReq := JSPluginInstallRequest{ PluginName: req.PluginName, Source: string(source), + Signature: string(signature), RuleName: req.PluginName, AutoStart: true, } diff --git a/pkg/plugin/audit/audit.go b/pkg/plugin/audit/audit.go new file mode 100644 index 0000000..88db7a8 --- /dev/null +++ b/pkg/plugin/audit/audit.go @@ -0,0 +1,154 @@ +package audit + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "sync" + "time" +) + +// EventType 审计事件类型 +type EventType string + +const ( + EventPluginInstall EventType = "plugin_install" + EventPluginUninstall EventType = "plugin_uninstall" + EventPluginStart EventType = "plugin_start" + EventPluginStop EventType = "plugin_stop" + EventPluginVerify EventType = "plugin_verify" + EventPluginReject EventType = "plugin_reject" + EventConfigChange EventType = "config_change" +) + +// Event 审计事件 +type Event struct { + Timestamp time.Time `json:"timestamp"` + Type EventType `json:"type"` + PluginName string `json:"plugin_name,omitempty"` + Version string `json:"version,omitempty"` + ClientID string `json:"client_id,omitempty"` + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Details map[string]string `json:"details,omitempty"` +} + +// Logger 审计日志记录器 +type Logger struct { + path string + file *os.File + mu sync.Mutex + enabled bool +} + +var ( + defaultLogger *Logger + loggerOnce sync.Once +) + +// NewLogger 创建审计日志记录器 +func NewLogger(dataDir string) (*Logger, error) { + path := filepath.Join(dataDir, "audit.log") + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return nil, err + } + + return &Logger{path: path, file: file, enabled: true}, nil +} + +// InitDefault 初始化默认日志记录器 +func InitDefault(dataDir string) error { + var err error + loggerOnce.Do(func() { + defaultLogger, err = NewLogger(dataDir) + }) + return err +} + +// Log 记录审计事件 +func (l *Logger) Log(event Event) { + if l == nil || !l.enabled { + return + } + + event.Timestamp = time.Now() + l.mu.Lock() + defer l.mu.Unlock() + + data, err := json.Marshal(event) + if err != nil { + log.Printf("[Audit] Marshal error: %v", err) + return + } + + if _, err := l.file.Write(append(data, '\n')); err != nil { + log.Printf("[Audit] Write error: %v", err) + } +} + +// Close 关闭日志文件 +func (l *Logger) Close() error { + if l == nil || l.file == nil { + return nil + } + return l.file.Close() +} + +// LogEvent 使用默认记录器记录事件 +func LogEvent(event Event) { + if defaultLogger != nil { + defaultLogger.Log(event) + } +} + +// LogPluginInstall 记录插件安装事件 +func LogPluginInstall(pluginName, version, clientID string, success bool, msg string) { + LogEvent(Event{ + Type: EventPluginInstall, + PluginName: pluginName, + Version: version, + ClientID: clientID, + Success: success, + Message: msg, + }) +} + +// LogPluginVerify 记录插件验证事件 +func LogPluginVerify(pluginName, version string, success bool, msg string) { + LogEvent(Event{ + Type: EventPluginVerify, + PluginName: pluginName, + Version: version, + Success: success, + Message: msg, + }) +} + +// LogPluginReject 记录插件拒绝事件 +func LogPluginReject(pluginName, version, reason string) { + LogEvent(Event{ + Type: EventPluginReject, + PluginName: pluginName, + Version: version, + Success: false, + Message: reason, + }) +} + +// LogWithDetails 记录带详情的事件 +func LogWithDetails(eventType EventType, pluginName string, success bool, msg string, details map[string]string) { + LogEvent(Event{ + Type: eventType, + PluginName: pluginName, + Success: success, + Message: msg, + Details: details, + }) +} diff --git a/pkg/plugin/script/js.go b/pkg/plugin/script/js.go index 777db07..4b1aaa6 100644 --- a/pkg/plugin/script/js.go +++ b/pkg/plugin/script/js.go @@ -48,6 +48,11 @@ func (p *JSPlugin) SetSandbox(sandbox *Sandbox) { // init 初始化 JS 运行时 func (p *JSPlugin) init() error { + // 设置栈深度限制(防止递归攻击) + if p.sandbox.MaxStackDepth > 0 { + p.vm.SetMaxCallStackSize(p.sandbox.MaxStackDepth) + } + // 注入基础 API p.vm.Set("log", p.jsLog) p.vm.Set("config", p.jsGetConfig) diff --git a/pkg/plugin/script/sandbox.go b/pkg/plugin/script/sandbox.go index 2c1291c..d65178b 100644 --- a/pkg/plugin/script/sandbox.go +++ b/pkg/plugin/script/sandbox.go @@ -21,6 +21,10 @@ type Sandbox struct { MaxReadSize int64 // 最大文件写入大小 (bytes) MaxWriteSize int64 + // 最大内存使用量 (bytes),0 表示不限制 + MaxMemory int64 + // 最大调用栈深度 + MaxStackDepth int } // DefaultSandbox 返回默认沙箱配置(最小权限) @@ -30,8 +34,10 @@ func DefaultSandbox() *Sandbox { WritablePaths: []string{}, DeniedPaths: defaultDeniedPaths(), AllowNetwork: false, - MaxReadSize: 10 * 1024 * 1024, // 10MB - MaxWriteSize: 1 * 1024 * 1024, // 1MB + MaxReadSize: 10 * 1024 * 1024, // 10MB + MaxWriteSize: 1 * 1024 * 1024, // 1MB + MaxMemory: 64 * 1024 * 1024, // 64MB + MaxStackDepth: 1000, // 最大调用栈深度 } } diff --git a/pkg/plugin/sign/official.go b/pkg/plugin/sign/official.go index 967a9f0..61fdc81 100644 --- a/pkg/plugin/sign/official.go +++ b/pkg/plugin/sign/official.go @@ -2,17 +2,21 @@ package sign import ( "crypto/ed25519" + "encoding/json" "fmt" + "io" + "log" + "net/http" "sync" "time" ) // KeyEntry 密钥条目 type KeyEntry struct { - ID string // 密钥 ID - PublicKey string // Base64 编码的公钥 - ValidFrom time.Time // 生效时间 - RevokedAt time.Time // 吊销时间(零值表示未吊销) + ID string `json:"id"` + PublicKey string `json:"public_key"` + ValidFrom time.Time `json:"valid_from"` + RevokedAt time.Time `json:"revoked_at,omitempty"` } // 官方公钥列表(支持密钥轮换) @@ -28,8 +32,30 @@ var officialKeys = []KeyEntry{ var ( keyCache map[string]ed25519.PublicKey keyCacheOnce sync.Once + keyMu sync.RWMutex + remoteKeys []KeyEntry ) +// KeyListConfig 远程公钥列表配置 +type KeyListConfig struct { + RemoteURL string + FetchInterval time.Duration + RequestTimeout time.Duration +} + +var keyListConfig = KeyListConfig{ + RemoteURL: "", + FetchInterval: 24 * time.Hour, + RequestTimeout: 10 * time.Second, +} + +// SetKeyListConfig 设置远程公钥列表配置 +func SetKeyListConfig(cfg KeyListConfig) { + keyMu.Lock() + defer keyMu.Unlock() + keyListConfig = cfg +} + // initKeyCache 初始化密钥缓存 func initKeyCache() { keyCache = make(map[string]ed25519.PublicKey) @@ -49,7 +75,10 @@ func GetOfficialPublicKey() (ed25519.PublicKey, error) { func GetPublicKeyByID(keyID string) (ed25519.PublicKey, error) { keyCacheOnce.Do(initKeyCache) + keyMu.RLock() pub, ok := keyCache[keyID] + keyMu.RUnlock() + if !ok { return nil, fmt.Errorf("unknown key ID: %s", keyID) } @@ -58,11 +87,20 @@ func GetPublicKeyByID(keyID string) (ed25519.PublicKey, error) { // IsKeyRevoked 检查密钥是否已吊销 func IsKeyRevoked(keyID string) bool { + // 先检查内置密钥 for _, entry := range officialKeys { if entry.ID == keyID { return !entry.RevokedAt.IsZero() } } + // 再检查远程密钥 + keyMu.RLock() + defer keyMu.RUnlock() + for _, entry := range remoteKeys { + if entry.ID == keyID { + return !entry.RevokedAt.IsZero() + } + } return true // 未知密钥视为已吊销 } @@ -73,5 +111,108 @@ func GetKeyEntry(keyID string) *KeyEntry { return &officialKeys[i] } } + keyMu.RLock() + defer keyMu.RUnlock() + for i := range remoteKeys { + if remoteKeys[i].ID == keyID { + return &remoteKeys[i] + } + } + return nil +} + +// KeyList 远程公钥列表结构 +type KeyList struct { + Version int `json:"version"` + UpdatedAt int64 `json:"updated_at"` + Keys []KeyEntry `json:"keys"` + Signature string `json:"signature"` +} + +// FetchRemoteKeyList 从远程拉取公钥列表 +func FetchRemoteKeyList() error { + keyMu.RLock() + cfg := keyListConfig + keyMu.RUnlock() + + if cfg.RemoteURL == "" { + return nil + } + + client := &http.Client{Timeout: cfg.RequestTimeout} + resp, err := client.Get(cfg.RemoteURL) + if err != nil { + return fmt.Errorf("fetch key list: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("fetch key list: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read key list: %w", err) + } + + var list KeyList + if err := json.Unmarshal(body, &list); err != nil { + return fmt.Errorf("parse key list: %w", err) + } + + // 验证签名(使用内置公钥验证) + if err := verifyKeyListSignature(&list); err != nil { + return fmt.Errorf("verify key list: %w", err) + } + + return updateKeyCache(&list) +} + +// verifyKeyListSignature 验证公钥列表签名 +func verifyKeyListSignature(list *KeyList) error { + if list.Signature == "" { + return fmt.Errorf("missing signature") + } + + // 使用内置公钥验证(必须用内置密钥签名) + pubKey, err := GetOfficialPublicKey() + if err != nil { + return err + } + + signData := struct { + Version int `json:"version"` + UpdatedAt int64 `json:"updated_at"` + Keys []KeyEntry `json:"keys"` + }{ + Version: list.Version, + UpdatedAt: list.UpdatedAt, + Keys: list.Keys, + } + + data, err := json.Marshal(signData) + if err != nil { + return err + } + + return VerifyBase64(pubKey, data, list.Signature) +} + +// updateKeyCache 更新公钥缓存 +func updateKeyCache(list *KeyList) error { + keyMu.Lock() + defer keyMu.Unlock() + + // 保存远程密钥列表 + remoteKeys = list.Keys + + // 更新缓存 + for _, entry := range list.Keys { + if pub, err := DecodePublicKey(entry.PublicKey); err == nil { + keyCache[entry.ID] = pub + } + } + + log.Printf("[KeyList] Updated with %d keys", len(list.Keys)) return nil } diff --git a/pkg/plugin/sign/revocation.go b/pkg/plugin/sign/revocation.go index 5b80936..2fcbe98 100644 --- a/pkg/plugin/sign/revocation.go +++ b/pkg/plugin/sign/revocation.go @@ -1,7 +1,13 @@ package sign import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" "sync" + "time" ) // RevocationEntry 撤销条目 @@ -20,7 +26,7 @@ type RevocationList struct { Signature string `json:"signature"` // 列表签名 } -// 内置撤销列表(编译时确定) +// 内置撤销列表(编译时确定,作为 fallback) var builtinRevocations = []RevocationEntry{ // 示例:{PluginName: "malicious-plugin", Reason: "security vulnerability"} } @@ -28,8 +34,42 @@ var builtinRevocations = []RevocationEntry{ var ( revocationCache map[string][]RevocationEntry // pluginName -> entries revocationCacheOnce sync.Once + revocationMu sync.RWMutex + currentListVersion int + lastFetchTime time.Time ) +// RevocationConfig 远程撤销列表配置 +type RevocationConfig struct { + RemoteURL string // 远程撤销列表 URL + FetchInterval time.Duration // 拉取间隔 + RequestTimeout time.Duration // 请求超时 + VerifySignature bool // 是否验证签名 +} + +var defaultRevocationConfig = RevocationConfig{ + RemoteURL: "", // 默认为空,不启用远程拉取 + FetchInterval: 1 * time.Hour, + RequestTimeout: 10 * time.Second, + VerifySignature: true, +} + +var revocationConfig = defaultRevocationConfig + +// SetRevocationConfig 设置远程撤销列表配置 +func SetRevocationConfig(cfg RevocationConfig) { + revocationMu.Lock() + defer revocationMu.Unlock() + revocationConfig = cfg +} + +// GetRevocationConfig 获取当前配置 +func GetRevocationConfig() RevocationConfig { + revocationMu.RLock() + defer revocationMu.RUnlock() + return revocationConfig +} + // initRevocationCache 初始化撤销缓存 func initRevocationCache() { revocationCache = make(map[string][]RevocationEntry) @@ -43,6 +83,9 @@ func initRevocationCache() { func IsPluginRevoked(name, version string) (bool, string) { revocationCacheOnce.Do(initRevocationCache) + revocationMu.RLock() + defer revocationMu.RUnlock() + entries, ok := revocationCache[name] if !ok { return false, "" @@ -56,3 +99,144 @@ func IsPluginRevoked(name, version string) (bool, string) { } return false, "" } + +// FetchRemoteRevocationList 从远程拉取撤销列表 +func FetchRemoteRevocationList() error { + cfg := GetRevocationConfig() + if cfg.RemoteURL == "" { + return nil // 未配置远程 URL,跳过 + } + + // 检查是否需要刷新 + revocationMu.RLock() + if time.Since(lastFetchTime) < cfg.FetchInterval { + revocationMu.RUnlock() + return nil + } + revocationMu.RUnlock() + + // 发起 HTTP 请求 + client := &http.Client{Timeout: cfg.RequestTimeout} + resp, err := client.Get(cfg.RemoteURL) + if err != nil { + return fmt.Errorf("fetch revocation list: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("fetch revocation list: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read revocation list: %w", err) + } + + var list RevocationList + if err := json.Unmarshal(body, &list); err != nil { + return fmt.Errorf("parse revocation list: %w", err) + } + + // 验证签名 + if cfg.VerifySignature { + if err := verifyRevocationListSignature(&list); err != nil { + return fmt.Errorf("verify revocation list: %w", err) + } + } + + // 更新缓存 + return updateRevocationCache(&list) +} + +// verifyRevocationListSignature 验证撤销列表签名 +func verifyRevocationListSignature(list *RevocationList) error { + if list.Signature == "" { + return fmt.Errorf("missing signature") + } + + // 获取官方公钥 + pubKey, err := GetOfficialPublicKey() + if err != nil { + return fmt.Errorf("get public key: %w", err) + } + + // 构造待签名数据(不含签名字段) + signData := struct { + Version int `json:"version"` + UpdatedAt int64 `json:"updated_at"` + Entries []RevocationEntry `json:"entries"` + }{ + Version: list.Version, + UpdatedAt: list.UpdatedAt, + Entries: list.Entries, + } + + data, err := json.Marshal(signData) + if err != nil { + return fmt.Errorf("marshal sign data: %w", err) + } + + return VerifyBase64(pubKey, data, list.Signature) +} + +// updateRevocationCache 更新撤销缓存 +func updateRevocationCache(list *RevocationList) error { + revocationMu.Lock() + defer revocationMu.Unlock() + + // 检查版本号,防止回滚攻击 + if list.Version < currentListVersion { + return fmt.Errorf("revocation list version rollback: %d < %d", list.Version, currentListVersion) + } + + // 重建缓存:先加载内置列表,再合并远程列表 + newCache := make(map[string][]RevocationEntry) + for _, entry := range builtinRevocations { + newCache[entry.PluginName] = append(newCache[entry.PluginName], entry) + } + for _, entry := range list.Entries { + newCache[entry.PluginName] = append(newCache[entry.PluginName], entry) + } + + revocationCache = newCache + currentListVersion = list.Version + lastFetchTime = time.Now() + + log.Printf("[Revocation] Updated to version %d with %d entries", list.Version, len(list.Entries)) + return nil +} + +// StartRevocationRefresher 启动后台刷新协程 +func StartRevocationRefresher(stopCh <-chan struct{}) { + cfg := GetRevocationConfig() + if cfg.RemoteURL == "" { + return + } + + // 立即执行一次 + if err := FetchRemoteRevocationList(); err != nil { + log.Printf("[Revocation] Initial fetch failed: %v", err) + } + + ticker := time.NewTicker(cfg.FetchInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := FetchRemoteRevocationList(); err != nil { + log.Printf("[Revocation] Refresh failed: %v", err) + } + case <-stopCh: + log.Printf("[Revocation] Refresher stopped") + return + } + } +} + +// GetRevocationListVersion 获取当前撤销列表版本 +func GetRevocationListVersion() int { + revocationMu.RLock() + defer revocationMu.RUnlock() + return currentListVersion +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index b8e6e55..721cebd 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -32,9 +32,9 @@ export const enablePlugin = (name: string) => post(`/plugin/${name}/enable`) export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`) // 扩展商店 -export const getStorePlugins = () => get<{ plugins: StorePluginInfo[], store_url: string }>('/store/plugins') -export const installStorePlugin = (pluginName: string, downloadUrl: string, clientId: string) => - post('/store/install', { plugin_name: pluginName, download_url: downloadUrl, client_id: clientId }) +export const getStorePlugins = () => get<{ plugins: StorePluginInfo[] }>('/store/plugins') +export const installStorePlugin = (pluginName: string, downloadUrl: string, signatureUrl: string, clientId: string) => + post('/store/install', { plugin_name: pluginName, download_url: downloadUrl, signature_url: signatureUrl, client_id: clientId }) // 客户端插件配置 export const getClientPluginConfig = (clientId: string, pluginName: string) => diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 2277775..0a39d82 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -110,6 +110,7 @@ export interface StorePluginInfo { author: string icon?: string download_url?: string + signature_url?: string } // JS 插件信息 diff --git a/web/src/views/PluginsView.vue b/web/src/views/PluginsView.vue index b558ab9..c297fe1 100644 --- a/web/src/views/PluginsView.vue +++ b/web/src/views/PluginsView.vue @@ -19,7 +19,6 @@ const plugins = ref([]) const storePlugins = ref([]) const jsPlugins = ref([]) const clients = ref([]) -const storeUrl = ref('') const loading = ref(true) const storeLoading = ref(false) const jsLoading = ref(false) @@ -41,7 +40,6 @@ const loadStorePlugins = async () => { try { const { data } = await getStorePlugins() storePlugins.value = data.plugins || [] - storeUrl.value = data.store_url || '' } catch (e) { console.error('Failed to load store plugins', e) } finally { @@ -165,11 +163,16 @@ const handleInstallStorePlugin = async () => { message.error('该插件没有下载地址') return } + if (!selectedStorePlugin.value.signature_url) { + message.error('该插件没有签名文件') + return + } installing.value = true try { await installStorePlugin( selectedStorePlugin.value.name, selectedStorePlugin.value.download_url, + selectedStorePlugin.value.signature_url, selectedClientId.value ) message.success(`已安装 ${selectedStorePlugin.value.name} 到客户端`) @@ -258,8 +261,7 @@ onMounted(() => { - - + @@ -273,7 +275,7 @@ onMounted(() => {