1111
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 47s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 47s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m3s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 51s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m6s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 50s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 59s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 51s
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 47s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 47s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m3s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 51s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m6s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 50s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 59s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 51s
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/gotunnel/pkg/crypto"
|
"github.com/gotunnel/pkg/crypto"
|
||||||
"github.com/gotunnel/pkg/plugin"
|
"github.com/gotunnel/pkg/plugin"
|
||||||
"github.com/gotunnel/pkg/plugin/builtin"
|
"github.com/gotunnel/pkg/plugin/builtin"
|
||||||
|
"github.com/gotunnel/pkg/plugin/sign"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -101,16 +102,67 @@ func loadJSPlugins(configs []config.JSPluginConfig) []tunnel.JSPluginEntry {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载签名文件
|
||||||
|
sigPath := cfg.SigPath
|
||||||
|
if sigPath == "" {
|
||||||
|
sigPath = cfg.Path + ".sig"
|
||||||
|
}
|
||||||
|
signature, err := os.ReadFile(sigPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[JSPlugin] Failed to load signature for %s: %v", cfg.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务端也验证签名,防止配置文件被篡改
|
||||||
|
if err := verifyPluginSignature(cfg.Name, string(source), string(signature)); err != nil {
|
||||||
|
log.Printf("[JSPlugin] Signature verification failed for %s: %v", cfg.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
plugins = append(plugins, tunnel.JSPluginEntry{
|
plugins = append(plugins, tunnel.JSPluginEntry{
|
||||||
Name: cfg.Name,
|
Name: cfg.Name,
|
||||||
Source: string(source),
|
Source: string(source),
|
||||||
|
Signature: string(signature),
|
||||||
AutoPush: cfg.AutoPush,
|
AutoPush: cfg.AutoPush,
|
||||||
Config: cfg.Config,
|
Config: cfg.Config,
|
||||||
AutoStart: cfg.AutoStart,
|
AutoStart: cfg.AutoStart,
|
||||||
})
|
})
|
||||||
|
|
||||||
log.Printf("[JSPlugin] Loaded: %s from %s", cfg.Name, cfg.Path)
|
log.Printf("[JSPlugin] Loaded: %s from %s (verified)", cfg.Name, cfg.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
return plugins
|
return plugins
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verifyPluginSignature 验证插件签名
|
||||||
|
func verifyPluginSignature(name, source, signature string) error {
|
||||||
|
// 解码签名
|
||||||
|
signed, err := sign.DecodeSignedPlugin(signature)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查插件是否被撤销
|
||||||
|
if revoked, reason := sign.IsPluginRevoked(name, signed.Payload.Version); revoked {
|
||||||
|
return fmt.Errorf("plugin revoked: %s", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查密钥是否已吊销
|
||||||
|
if sign.IsKeyRevoked(signed.Payload.KeyID) {
|
||||||
|
return fmt.Errorf("signing key revoked: %s", signed.Payload.KeyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取公钥
|
||||||
|
pubKey, err := sign.GetPublicKeyByID(signed.Payload.KeyID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证插件名称
|
||||||
|
if signed.Payload.Name != name {
|
||||||
|
return fmt.Errorf("name mismatch: %s vs %s", signed.Payload.Name, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
return sign.VerifyPlugin(pubKey, signed, source)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gotunnel/pkg/plugin"
|
"github.com/gotunnel/pkg/plugin"
|
||||||
"github.com/gotunnel/pkg/plugin/script"
|
"github.com/gotunnel/pkg/plugin/script"
|
||||||
|
"github.com/gotunnel/pkg/plugin/sign"
|
||||||
"github.com/gotunnel/pkg/protocol"
|
"github.com/gotunnel/pkg/protocol"
|
||||||
"github.com/gotunnel/pkg/relay"
|
"github.com/gotunnel/pkg/relay"
|
||||||
"github.com/hashicorp/yamux"
|
"github.com/hashicorp/yamux"
|
||||||
@@ -35,11 +36,13 @@ type Client struct {
|
|||||||
ID string
|
ID string
|
||||||
TLSEnabled bool
|
TLSEnabled bool
|
||||||
TLSConfig *tls.Config
|
TLSConfig *tls.Config
|
||||||
|
DataDir string // 数据目录
|
||||||
session *yamux.Session
|
session *yamux.Session
|
||||||
rules []protocol.ProxyRule
|
rules []protocol.ProxyRule
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
pluginRegistry *plugin.Registry
|
pluginRegistry *plugin.Registry
|
||||||
runningPlugins map[string]plugin.ClientPlugin // 运行中的客户端插件
|
runningPlugins map[string]plugin.ClientPlugin
|
||||||
|
versionStore *PluginVersionStore
|
||||||
pluginMu sync.RWMutex
|
pluginMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,14 +51,30 @@ func NewClient(serverAddr, token, id string) *Client {
|
|||||||
if id == "" {
|
if id == "" {
|
||||||
id = loadClientID()
|
id = loadClientID()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 默认数据目录
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
dataDir := filepath.Join(home, ".gotunnel")
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
ServerAddr: serverAddr,
|
ServerAddr: serverAddr,
|
||||||
Token: token,
|
Token: token,
|
||||||
ID: id,
|
ID: id,
|
||||||
|
DataDir: dataDir,
|
||||||
runningPlugins: make(map[string]plugin.ClientPlugin),
|
runningPlugins: make(map[string]plugin.ClientPlugin),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitVersionStore 初始化版本存储
|
||||||
|
func (c *Client) InitVersionStore() error {
|
||||||
|
store, err := NewPluginVersionStore(c.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.versionStore = store
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// getIDFilePath 获取 ID 文件路径
|
// getIDFilePath 获取 ID 文件路径
|
||||||
func getIDFilePath() string {
|
func getIDFilePath() string {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
@@ -478,6 +497,14 @@ func (c *Client) handleJSPluginInstall(stream net.Conn, msg *protocol.Message) {
|
|||||||
|
|
||||||
log.Printf("[Client] Installing JS plugin: %s", req.PluginName)
|
log.Printf("[Client] Installing JS plugin: %s", req.PluginName)
|
||||||
|
|
||||||
|
// 验证官方签名
|
||||||
|
if err := c.verifyJSPluginSignature(req.PluginName, req.Source, req.Signature); err != nil {
|
||||||
|
log.Printf("[Client] JS plugin %s signature verification failed: %v", req.PluginName, err)
|
||||||
|
c.sendJSPluginResult(stream, req.PluginName, false, "signature verification failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[Client] JS plugin %s signature verified", req.PluginName)
|
||||||
|
|
||||||
// 创建 JS 插件
|
// 创建 JS 插件
|
||||||
jsPlugin, err := script.NewJSPlugin(req.PluginName, req.Source)
|
jsPlugin, err := script.NewJSPlugin(req.PluginName, req.Source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -493,6 +520,14 @@ func (c *Client) handleJSPluginInstall(stream net.Conn, msg *protocol.Message) {
|
|||||||
log.Printf("[Client] JS plugin %s installed", req.PluginName)
|
log.Printf("[Client] JS plugin %s installed", req.PluginName)
|
||||||
c.sendJSPluginResult(stream, req.PluginName, true, "")
|
c.sendJSPluginResult(stream, req.PluginName, true, "")
|
||||||
|
|
||||||
|
// 保存版本信息(防止降级攻击)
|
||||||
|
if c.versionStore != nil {
|
||||||
|
signed, _ := sign.DecodeSignedPlugin(req.Signature)
|
||||||
|
if signed != nil {
|
||||||
|
c.versionStore.SetVersion(req.PluginName, signed.Payload.Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 自动启动
|
// 自动启动
|
||||||
if req.AutoStart {
|
if req.AutoStart {
|
||||||
c.startJSPlugin(jsPlugin, req)
|
c.startJSPlugin(jsPlugin, req)
|
||||||
@@ -530,3 +565,58 @@ func (c *Client) startJSPlugin(handler plugin.ClientPlugin, req protocol.JSPlugi
|
|||||||
|
|
||||||
log.Printf("[Client] JS plugin %s started at %s", req.PluginName, localAddr)
|
log.Printf("[Client] JS plugin %s started at %s", req.PluginName, localAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verifyJSPluginSignature 验证 JS 插件签名
|
||||||
|
func (c *Client) verifyJSPluginSignature(pluginName, source, signature string) error {
|
||||||
|
if signature == "" {
|
||||||
|
return fmt.Errorf("missing signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解码签名
|
||||||
|
signed, err := sign.DecodeSignedPlugin(signature)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查插件是否被撤销
|
||||||
|
if revoked, reason := sign.IsPluginRevoked(pluginName, signed.Payload.Version); revoked {
|
||||||
|
return fmt.Errorf("plugin %s v%s has been revoked: %s",
|
||||||
|
pluginName, signed.Payload.Version, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查密钥是否已吊销
|
||||||
|
if sign.IsKeyRevoked(signed.Payload.KeyID) {
|
||||||
|
return fmt.Errorf("signing key %s has been revoked", signed.Payload.KeyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 KeyID 获取对应公钥
|
||||||
|
pubKey, err := sign.GetPublicKeyByID(signed.Payload.KeyID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证插件名称匹配
|
||||||
|
if signed.Payload.Name != pluginName {
|
||||||
|
return fmt.Errorf("plugin name mismatch: expected %s, got %s",
|
||||||
|
pluginName, signed.Payload.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证签名和源码哈希
|
||||||
|
if err := sign.VerifyPlugin(pubKey, signed, source); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查版本降级攻击
|
||||||
|
if c.versionStore != nil {
|
||||||
|
currentVer := c.versionStore.GetVersion(pluginName)
|
||||||
|
if currentVer != "" {
|
||||||
|
cmp := sign.CompareVersions(signed.Payload.Version, currentVer)
|
||||||
|
if cmp < 0 {
|
||||||
|
return fmt.Errorf("version downgrade rejected: %s < %s",
|
||||||
|
signed.Payload.Version, currentVer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
67
internal/client/tunnel/version_store.go
Normal file
67
internal/client/tunnel/version_store.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package tunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginVersionStore 插件版本存储
|
||||||
|
type PluginVersionStore struct {
|
||||||
|
path string
|
||||||
|
versions map[string]string // pluginName -> version
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPluginVersionStore 创建版本存储
|
||||||
|
func NewPluginVersionStore(dataDir string) (*PluginVersionStore, error) {
|
||||||
|
store := &PluginVersionStore{
|
||||||
|
path: filepath.Join(dataDir, "plugin_versions.json"),
|
||||||
|
versions: make(map[string]string),
|
||||||
|
}
|
||||||
|
if err := store.load(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// load 从文件加载版本信息
|
||||||
|
func (s *PluginVersionStore) load() error {
|
||||||
|
data, err := os.ReadFile(s.path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &s.versions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// save 保存版本信息到文件
|
||||||
|
func (s *PluginVersionStore) save() error {
|
||||||
|
dir := filepath.Dir(s.path)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(s.versions, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(s.path, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersion 获取插件版本
|
||||||
|
func (s *PluginVersionStore) GetVersion(name string) string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.versions[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVersion 设置插件版本
|
||||||
|
func (s *PluginVersionStore) SetVersion(name, version string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.versions[name] = version
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ type ServerConfig struct {
|
|||||||
type JSPluginConfig struct {
|
type JSPluginConfig struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Path string `yaml:"path"` // JS 文件路径
|
Path string `yaml:"path"` // JS 文件路径
|
||||||
|
SigPath string `yaml:"sig_path,omitempty"` // 签名文件路径 (默认为 path + ".sig")
|
||||||
AutoPush []string `yaml:"auto_push,omitempty"` // 自动推送到的客户端 ID 列表
|
AutoPush []string `yaml:"auto_push,omitempty"` // 自动推送到的客户端 ID 列表
|
||||||
Config map[string]string `yaml:"config,omitempty"` // 插件配置
|
Config map[string]string `yaml:"config,omitempty"` // 插件配置
|
||||||
AutoStart bool `yaml:"auto_start,omitempty"` // 是否自动启动
|
AutoStart bool `yaml:"auto_start,omitempty"` // 是否自动启动
|
||||||
@@ -27,9 +28,12 @@ type JSPluginConfig struct {
|
|||||||
|
|
||||||
// PluginStoreSettings 扩展商店设置
|
// PluginStoreSettings 扩展商店设置
|
||||||
type PluginStoreSettings struct {
|
type PluginStoreSettings struct {
|
||||||
URL string `yaml:"url"` // 扩展商店URL,例如 GitHub 仓库的 raw URL
|
// 保留结构体以便未来扩展,但不暴露 URL 配置
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 官方插件商店(不可配置)
|
||||||
|
const OfficialPluginStoreURL = "https://git.92coco.cn:8443/flik/GoTunnel-Plugins/raw/branch/main/store.json"
|
||||||
|
|
||||||
// ServerSettings 服务端设置
|
// ServerSettings 服务端设置
|
||||||
type ServerSettings struct {
|
type ServerSettings struct {
|
||||||
BindAddr string `yaml:"bind_addr"`
|
BindAddr string `yaml:"bind_addr"`
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type PluginData struct {
|
|||||||
type JSPlugin struct {
|
type JSPlugin struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
|
Signature string `json:"signature"` // 官方签名 (Base64)
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
AutoPush []string `json:"auto_push"`
|
AutoPush []string `json:"auto_push"`
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ func (s *SQLiteStore) init() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 迁移:添加 signature 列
|
||||||
|
s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN signature TEXT NOT NULL DEFAULT ''`)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +326,7 @@ func (s *SQLiteStore) GetAllJSPlugins() ([]JSPlugin, error) {
|
|||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT name, source, description, author, auto_push, config, auto_start, enabled
|
SELECT name, source, signature, description, author, auto_push, config, auto_start, enabled
|
||||||
FROM js_plugins
|
FROM js_plugins
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -336,7 +339,7 @@ func (s *SQLiteStore) GetAllJSPlugins() ([]JSPlugin, error) {
|
|||||||
var p JSPlugin
|
var p JSPlugin
|
||||||
var autoPushJSON, configJSON string
|
var autoPushJSON, configJSON string
|
||||||
var autoStart, enabled int
|
var autoStart, enabled int
|
||||||
err := rows.Scan(&p.Name, &p.Source, &p.Description, &p.Author,
|
err := rows.Scan(&p.Name, &p.Source, &p.Signature, &p.Description, &p.Author,
|
||||||
&autoPushJSON, &configJSON, &autoStart, &enabled)
|
&autoPushJSON, &configJSON, &autoStart, &enabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -359,9 +362,9 @@ func (s *SQLiteStore) GetJSPlugin(name string) (*JSPlugin, error) {
|
|||||||
var autoPushJSON, configJSON string
|
var autoPushJSON, configJSON string
|
||||||
var autoStart, enabled int
|
var autoStart, enabled int
|
||||||
err := s.db.QueryRow(`
|
err := s.db.QueryRow(`
|
||||||
SELECT name, source, description, author, auto_push, config, auto_start, enabled
|
SELECT name, source, signature, description, author, auto_push, config, auto_start, enabled
|
||||||
FROM js_plugins WHERE name = ?
|
FROM js_plugins WHERE name = ?
|
||||||
`, name).Scan(&p.Name, &p.Source, &p.Description, &p.Author,
|
`, name).Scan(&p.Name, &p.Source, &p.Signature, &p.Description, &p.Author,
|
||||||
&autoPushJSON, &configJSON, &autoStart, &enabled)
|
&autoPushJSON, &configJSON, &autoStart, &enabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -390,9 +393,9 @@ func (s *SQLiteStore) SaveJSPlugin(p *JSPlugin) error {
|
|||||||
|
|
||||||
_, err := s.db.Exec(`
|
_, err := s.db.Exec(`
|
||||||
INSERT OR REPLACE INTO js_plugins
|
INSERT OR REPLACE INTO js_plugins
|
||||||
(name, source, description, author, auto_push, config, auto_start, enabled)
|
(name, source, signature, description, author, auto_push, config, auto_start, enabled)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, p.Name, p.Source, p.Description, p.Author,
|
`, p.Name, p.Source, p.Signature, p.Description, p.Author,
|
||||||
string(autoPushJSON), string(configJSON), autoStart, enabled)
|
string(autoPushJSON), string(configJSON), autoStart, enabled)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type ServerInterface interface {
|
|||||||
type JSPluginInstallRequest struct {
|
type JSPluginInstallRequest struct {
|
||||||
PluginName string `json:"plugin_name"`
|
PluginName string `json:"plugin_name"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
RuleName string `json:"rule_name"`
|
RuleName string `json:"rule_name"`
|
||||||
RemotePort int `json:"remote_port"`
|
RemotePort int `json:"remote_port"`
|
||||||
Config map[string]string `json:"config"`
|
Config map[string]string `json:"config"`
|
||||||
@@ -589,14 +590,8 @@ func (h *APIHandler) handleStorePlugins(rw http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := h.app.GetConfig()
|
cfg := h.app.GetConfig()
|
||||||
storeURL := cfg.PluginStore.URL
|
storeURL := config.OfficialPluginStoreURL
|
||||||
if storeURL == "" {
|
_ = cfg // 保留以便未来扩展
|
||||||
h.jsonResponse(rw, map[string]interface{}{
|
|
||||||
"plugins": []StorePluginInfo{},
|
|
||||||
"store_url": "",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从远程URL获取插件列表
|
// 从远程URL获取插件列表
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
@@ -939,6 +934,7 @@ func (h *APIHandler) pushJSPluginToClient(rw http.ResponseWriter, pluginName, cl
|
|||||||
req := JSPluginInstallRequest{
|
req := JSPluginInstallRequest{
|
||||||
PluginName: p.Name,
|
PluginName: p.Name,
|
||||||
Source: p.Source,
|
Source: p.Source,
|
||||||
|
Signature: p.Signature,
|
||||||
RuleName: p.Name,
|
RuleName: p.Name,
|
||||||
Config: p.Config,
|
Config: p.Config,
|
||||||
AutoStart: p.AutoStart,
|
AutoStart: p.AutoStart,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type Server struct {
|
|||||||
type JSPluginEntry struct {
|
type JSPluginEntry struct {
|
||||||
Name string
|
Name string
|
||||||
Source string
|
Source string
|
||||||
|
Signature string
|
||||||
AutoPush []string
|
AutoPush []string
|
||||||
Config map[string]string
|
Config map[string]string
|
||||||
AutoStart bool
|
AutoStart bool
|
||||||
@@ -873,6 +874,7 @@ func (s *Server) InstallJSPluginToClient(clientID string, req router.JSPluginIns
|
|||||||
installReq := protocol.JSPluginInstallRequest{
|
installReq := protocol.JSPluginInstallRequest{
|
||||||
PluginName: req.PluginName,
|
PluginName: req.PluginName,
|
||||||
Source: req.Source,
|
Source: req.Source,
|
||||||
|
Signature: req.Signature,
|
||||||
RuleName: req.RuleName,
|
RuleName: req.RuleName,
|
||||||
RemotePort: req.RemotePort,
|
RemotePort: req.RemotePort,
|
||||||
Config: req.Config,
|
Config: req.Config,
|
||||||
@@ -1035,6 +1037,7 @@ func (s *Server) autoPushJSPlugins(cs *ClientSession) {
|
|||||||
req := router.JSPluginInstallRequest{
|
req := router.JSPluginInstallRequest{
|
||||||
PluginName: jp.Name,
|
PluginName: jp.Name,
|
||||||
Source: jp.Source,
|
Source: jp.Source,
|
||||||
|
Signature: jp.Signature,
|
||||||
RuleName: jp.Name,
|
RuleName: jp.Name,
|
||||||
Config: jp.Config,
|
Config: jp.Config,
|
||||||
AutoStart: jp.AutoStart,
|
AutoStart: jp.AutoStart,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type JSPlugin struct {
|
|||||||
vm *goja.Runtime
|
vm *goja.Runtime
|
||||||
metadata plugin.Metadata
|
metadata plugin.Metadata
|
||||||
config map[string]string
|
config map[string]string
|
||||||
|
sandbox *Sandbox
|
||||||
running bool
|
running bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ func NewJSPlugin(name, source string) (*JSPlugin, error) {
|
|||||||
name: name,
|
name: name,
|
||||||
source: source,
|
source: source,
|
||||||
vm: goja.New(),
|
vm: goja.New(),
|
||||||
|
sandbox: DefaultSandbox(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := p.init(); err != nil {
|
if err := p.init(); err != nil {
|
||||||
@@ -39,6 +41,11 @@ func NewJSPlugin(name, source string) (*JSPlugin, error) {
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSandbox 设置沙箱配置
|
||||||
|
func (p *JSPlugin) SetSandbox(sandbox *Sandbox) {
|
||||||
|
p.sandbox = sandbox
|
||||||
|
}
|
||||||
|
|
||||||
// init 初始化 JS 运行时
|
// init 初始化 JS 运行时
|
||||||
func (p *JSPlugin) init() error {
|
func (p *JSPlugin) init() error {
|
||||||
// 注入基础 API
|
// 注入基础 API
|
||||||
@@ -231,22 +238,50 @@ func (p *JSPlugin) createFsAPI() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *JSPlugin) fsReadFile(path string) string {
|
func (p *JSPlugin) fsReadFile(path string) map[string]interface{} {
|
||||||
|
if err := p.sandbox.ValidateReadPath(path); err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "data": ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "data": ""}
|
||||||
|
}
|
||||||
|
if info.Size() > p.sandbox.MaxReadSize {
|
||||||
|
return map[string]interface{}{"error": "file too large", "data": ""}
|
||||||
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return map[string]interface{}{"error": err.Error(), "data": ""}
|
||||||
}
|
}
|
||||||
return string(data)
|
return map[string]interface{}{"error": "", "data": string(data)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *JSPlugin) fsWriteFile(path, content string) bool {
|
func (p *JSPlugin) fsWriteFile(path, content string) map[string]interface{} {
|
||||||
return os.WriteFile(path, []byte(content), 0644) == nil
|
if err := p.sandbox.ValidateWritePath(path); err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "ok": false}
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(content)) > p.sandbox.MaxWriteSize {
|
||||||
|
return map[string]interface{}{"error": "content too large", "ok": false}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(content), 0644)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "ok": false}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"error": "", "ok": true}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *JSPlugin) fsReadDir(path string) []map[string]interface{} {
|
func (p *JSPlugin) fsReadDir(path string) map[string]interface{} {
|
||||||
|
if err := p.sandbox.ValidateReadPath(path); err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "entries": nil}
|
||||||
|
}
|
||||||
|
|
||||||
entries, err := os.ReadDir(path)
|
entries, err := os.ReadDir(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return map[string]interface{}{"error": err.Error(), "entries": nil}
|
||||||
}
|
}
|
||||||
var result []map[string]interface{}
|
var result []map[string]interface{}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
@@ -257,15 +292,20 @@ func (p *JSPlugin) fsReadDir(path string) []map[string]interface{} {
|
|||||||
"size": info.Size(),
|
"size": info.Size(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return map[string]interface{}{"error": "", "entries": result}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *JSPlugin) fsStat(path string) map[string]interface{} {
|
func (p *JSPlugin) fsStat(path string) map[string]interface{} {
|
||||||
|
if err := p.sandbox.ValidateReadPath(path); err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return map[string]interface{}{"error": err.Error()}
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
|
"error": "",
|
||||||
"name": info.Name(),
|
"name": info.Name(),
|
||||||
"size": info.Size(),
|
"size": info.Size(),
|
||||||
"isDir": info.IsDir(),
|
"isDir": info.IsDir(),
|
||||||
@@ -273,17 +313,34 @@ func (p *JSPlugin) fsStat(path string) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *JSPlugin) fsExists(path string) bool {
|
func (p *JSPlugin) fsExists(path string) map[string]interface{} {
|
||||||
|
if err := p.sandbox.ValidateReadPath(path); err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "exists": false}
|
||||||
|
}
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
return err == nil
|
return map[string]interface{}{"error": "", "exists": err == nil}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *JSPlugin) fsMkdir(path string) bool {
|
func (p *JSPlugin) fsMkdir(path string) map[string]interface{} {
|
||||||
return os.MkdirAll(path, 0755) == nil
|
if err := p.sandbox.ValidateWritePath(path); err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "ok": false}
|
||||||
|
}
|
||||||
|
err := os.MkdirAll(path, 0755)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "ok": false}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"error": "", "ok": true}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *JSPlugin) fsRemove(path string) bool {
|
func (p *JSPlugin) fsRemove(path string) map[string]interface{} {
|
||||||
return os.RemoveAll(path) == nil
|
if err := p.sandbox.ValidateWritePath(path); err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "ok": false}
|
||||||
|
}
|
||||||
|
err := os.RemoveAll(path)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "ok": false}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"error": "", "ok": true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
155
pkg/plugin/script/sandbox.go
Normal file
155
pkg/plugin/script/sandbox.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package script
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sandbox 插件沙箱配置
|
||||||
|
type Sandbox struct {
|
||||||
|
// 允许访问的路径列表(绝对路径)
|
||||||
|
AllowedPaths []string
|
||||||
|
// 允许写入的路径列表(必须是 AllowedPaths 的子集)
|
||||||
|
WritablePaths []string
|
||||||
|
// 禁止访问的路径(黑名单,优先级高于白名单)
|
||||||
|
DeniedPaths []string
|
||||||
|
// 是否允许网络访问
|
||||||
|
AllowNetwork bool
|
||||||
|
// 最大文件读取大小 (bytes)
|
||||||
|
MaxReadSize int64
|
||||||
|
// 最大文件写入大小 (bytes)
|
||||||
|
MaxWriteSize int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSandbox 返回默认沙箱配置(最小权限)
|
||||||
|
func DefaultSandbox() *Sandbox {
|
||||||
|
return &Sandbox{
|
||||||
|
AllowedPaths: []string{},
|
||||||
|
WritablePaths: []string{},
|
||||||
|
DeniedPaths: defaultDeniedPaths(),
|
||||||
|
AllowNetwork: false,
|
||||||
|
MaxReadSize: 10 * 1024 * 1024, // 10MB
|
||||||
|
MaxWriteSize: 1 * 1024 * 1024, // 1MB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultDeniedPaths 返回默认禁止访问的路径
|
||||||
|
func defaultDeniedPaths() []string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
denied := []string{
|
||||||
|
"/etc/passwd",
|
||||||
|
"/etc/shadow",
|
||||||
|
"/etc/sudoers",
|
||||||
|
"/root",
|
||||||
|
"/.ssh",
|
||||||
|
"/.gnupg",
|
||||||
|
"/.aws",
|
||||||
|
"/.kube",
|
||||||
|
"/proc",
|
||||||
|
"/sys",
|
||||||
|
}
|
||||||
|
if home != "" {
|
||||||
|
denied = append(denied,
|
||||||
|
filepath.Join(home, ".ssh"),
|
||||||
|
filepath.Join(home, ".gnupg"),
|
||||||
|
filepath.Join(home, ".aws"),
|
||||||
|
filepath.Join(home, ".kube"),
|
||||||
|
filepath.Join(home, ".config"),
|
||||||
|
filepath.Join(home, ".local"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return denied
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateReadPath 验证读取路径是否允许
|
||||||
|
func (s *Sandbox) ValidateReadPath(path string) error {
|
||||||
|
return s.validatePath(path, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateWritePath 验证写入路径是否允许
|
||||||
|
func (s *Sandbox) ValidateWritePath(path string) error {
|
||||||
|
return s.validatePath(path, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sandbox) validatePath(path string, write bool) error {
|
||||||
|
// 清理路径,防止路径遍历攻击
|
||||||
|
cleanPath, err := s.cleanPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查黑名单(优先级最高)
|
||||||
|
if s.isDenied(cleanPath) {
|
||||||
|
return fmt.Errorf("access denied: path is in denied list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查白名单
|
||||||
|
allowedList := s.AllowedPaths
|
||||||
|
if write {
|
||||||
|
allowedList = s.WritablePaths
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allowedList) == 0 {
|
||||||
|
return fmt.Errorf("access denied: no paths allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.isAllowed(cleanPath, allowedList) {
|
||||||
|
if write {
|
||||||
|
return fmt.Errorf("access denied: path not in writable list")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("access denied: path not in allowed list")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanPath 清理并验证路径
|
||||||
|
func (s *Sandbox) cleanPath(path string) (string, error) {
|
||||||
|
// 转换为绝对路径
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理路径(解析 .. 和 .)
|
||||||
|
cleanPath := filepath.Clean(absPath)
|
||||||
|
|
||||||
|
// 检查符号链接(防止通过符号链接绕过限制)
|
||||||
|
realPath, err := filepath.EvalSymlinks(cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
// 文件可能不存在,使用清理后的路径
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("invalid path: %w", err)
|
||||||
|
}
|
||||||
|
realPath = cleanPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再次检查路径遍历
|
||||||
|
if strings.Contains(realPath, "..") {
|
||||||
|
return "", fmt.Errorf("path traversal detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
return realPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDenied 检查路径是否在黑名单中
|
||||||
|
func (s *Sandbox) isDenied(path string) bool {
|
||||||
|
for _, denied := range s.DeniedPaths {
|
||||||
|
if strings.HasPrefix(path, denied) || path == denied {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowed 检查路径是否在白名单中
|
||||||
|
func (s *Sandbox) isAllowed(path string, allowedList []string) bool {
|
||||||
|
for _, allowed := range allowedList {
|
||||||
|
if strings.HasPrefix(path, allowed) || path == allowed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
77
pkg/plugin/sign/official.go
Normal file
77
pkg/plugin/sign/official.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package sign
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyEntry 密钥条目
|
||||||
|
type KeyEntry struct {
|
||||||
|
ID string // 密钥 ID
|
||||||
|
PublicKey string // Base64 编码的公钥
|
||||||
|
ValidFrom time.Time // 生效时间
|
||||||
|
RevokedAt time.Time // 吊销时间(零值表示未吊销)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 官方公钥列表(支持密钥轮换)
|
||||||
|
var officialKeys = []KeyEntry{
|
||||||
|
{
|
||||||
|
ID: "official-v1",
|
||||||
|
PublicKey: "0A0xRthj0wgPg8X8GJZ6/EnNpAUw5v7O//XLty+P5Yw=",
|
||||||
|
ValidFrom: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
// 添加新密钥时,在此处追加
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
keyCache map[string]ed25519.PublicKey
|
||||||
|
keyCacheOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// initKeyCache 初始化密钥缓存
|
||||||
|
func initKeyCache() {
|
||||||
|
keyCache = make(map[string]ed25519.PublicKey)
|
||||||
|
for _, entry := range officialKeys {
|
||||||
|
if pub, err := DecodePublicKey(entry.PublicKey); err == nil {
|
||||||
|
keyCache[entry.ID] = pub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOfficialPublicKey 获取默认官方公钥(兼容旧接口)
|
||||||
|
func GetOfficialPublicKey() (ed25519.PublicKey, error) {
|
||||||
|
return GetPublicKeyByID("official-v1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicKeyByID 根据 ID 获取公钥
|
||||||
|
func GetPublicKeyByID(keyID string) (ed25519.PublicKey, error) {
|
||||||
|
keyCacheOnce.Do(initKeyCache)
|
||||||
|
|
||||||
|
pub, ok := keyCache[keyID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown key ID: %s", keyID)
|
||||||
|
}
|
||||||
|
return pub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsKeyRevoked 检查密钥是否已吊销
|
||||||
|
func IsKeyRevoked(keyID string) bool {
|
||||||
|
for _, entry := range officialKeys {
|
||||||
|
if entry.ID == keyID {
|
||||||
|
return !entry.RevokedAt.IsZero()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true // 未知密钥视为已吊销
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKeyEntry 获取密钥条目
|
||||||
|
func GetKeyEntry(keyID string) *KeyEntry {
|
||||||
|
for i := range officialKeys {
|
||||||
|
if officialKeys[i].ID == keyID {
|
||||||
|
return &officialKeys[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
107
pkg/plugin/sign/payload.go
Normal file
107
pkg/plugin/sign/payload.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package sign
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginPayload 插件签名载荷
|
||||||
|
type PluginPayload struct {
|
||||||
|
Name string `json:"name"` // 插件名称
|
||||||
|
Version string `json:"version"` // 版本号
|
||||||
|
SourceHash string `json:"source_hash"` // 源码 SHA256
|
||||||
|
KeyID string `json:"key_id"` // 签名密钥 ID
|
||||||
|
Timestamp int64 `json:"timestamp"` // 签名时间戳
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignedPlugin 已签名的插件
|
||||||
|
type SignedPlugin struct {
|
||||||
|
Payload PluginPayload `json:"payload"`
|
||||||
|
Signature string `json:"signature"` // Base64 签名
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeSource 规范化源码(统一换行符)
|
||||||
|
func NormalizeSource(source string) string {
|
||||||
|
// 统一换行符为 LF
|
||||||
|
normalized := strings.ReplaceAll(source, "\r\n", "\n")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||||
|
// 去除尾部空白
|
||||||
|
normalized = strings.TrimRight(normalized, " \t\n")
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashSource 计算源码哈希
|
||||||
|
func HashSource(source string) string {
|
||||||
|
normalized := NormalizeSource(source)
|
||||||
|
hash := sha256.Sum256([]byte(normalized))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePayload 创建签名载荷
|
||||||
|
func CreatePayload(name, version, source, keyID string) *PluginPayload {
|
||||||
|
return &PluginPayload{
|
||||||
|
Name: name,
|
||||||
|
Version: version,
|
||||||
|
SourceHash: HashSource(source),
|
||||||
|
KeyID: keyID,
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignPlugin 签名插件
|
||||||
|
func SignPlugin(priv ed25519.PrivateKey, payload *PluginPayload) (*SignedPlugin, error) {
|
||||||
|
// 序列化载荷
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签名
|
||||||
|
sig := SignBase64(priv, data)
|
||||||
|
|
||||||
|
return &SignedPlugin{
|
||||||
|
Payload: *payload,
|
||||||
|
Signature: sig,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPlugin 验证插件签名
|
||||||
|
func VerifyPlugin(pub ed25519.PublicKey, signed *SignedPlugin, source string) error {
|
||||||
|
// 验证源码哈希
|
||||||
|
expectedHash := HashSource(source)
|
||||||
|
if signed.Payload.SourceHash != expectedHash {
|
||||||
|
return fmt.Errorf("source hash mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化载荷
|
||||||
|
data, err := json.Marshal(signed.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
return VerifyBase64(pub, data, signed.Signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeSignedPlugin 编码已签名插件为 JSON
|
||||||
|
func EncodeSignedPlugin(sp *SignedPlugin) (string, error) {
|
||||||
|
data, err := json.Marshal(sp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeSignedPlugin 从 JSON 解码已签名插件
|
||||||
|
func DecodeSignedPlugin(data string) (*SignedPlugin, error) {
|
||||||
|
var sp SignedPlugin
|
||||||
|
if err := json.Unmarshal([]byte(data), &sp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &sp, nil
|
||||||
|
}
|
||||||
58
pkg/plugin/sign/revocation.go
Normal file
58
pkg/plugin/sign/revocation.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package sign
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RevocationEntry 撤销条目
|
||||||
|
type RevocationEntry struct {
|
||||||
|
PluginName string `json:"plugin_name"` // 插件名称
|
||||||
|
Version string `json:"version,omitempty"` // 特定版本(空表示所有版本)
|
||||||
|
Reason string `json:"reason"` // 撤销原因
|
||||||
|
RevokedAt int64 `json:"revoked_at"` // 撤销时间戳
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevocationList 撤销列表
|
||||||
|
type RevocationList struct {
|
||||||
|
Version int `json:"version"` // 列表版本
|
||||||
|
UpdatedAt int64 `json:"updated_at"` // 更新时间
|
||||||
|
Entries []RevocationEntry `json:"entries"` // 撤销条目
|
||||||
|
Signature string `json:"signature"` // 列表签名
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内置撤销列表(编译时确定)
|
||||||
|
var builtinRevocations = []RevocationEntry{
|
||||||
|
// 示例:{PluginName: "malicious-plugin", Reason: "security vulnerability"}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
revocationCache map[string][]RevocationEntry // pluginName -> entries
|
||||||
|
revocationCacheOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// initRevocationCache 初始化撤销缓存
|
||||||
|
func initRevocationCache() {
|
||||||
|
revocationCache = make(map[string][]RevocationEntry)
|
||||||
|
for _, entry := range builtinRevocations {
|
||||||
|
revocationCache[entry.PluginName] = append(
|
||||||
|
revocationCache[entry.PluginName], entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPluginRevoked 检查插件是否被撤销
|
||||||
|
func IsPluginRevoked(name, version string) (bool, string) {
|
||||||
|
revocationCacheOnce.Do(initRevocationCache)
|
||||||
|
|
||||||
|
entries, ok := revocationCache[name]
|
||||||
|
if !ok {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
// 空版本表示所有版本都被撤销
|
||||||
|
if entry.Version == "" || entry.Version == version {
|
||||||
|
return true, entry.Reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
92
pkg/plugin/sign/sign.go
Normal file
92
pkg/plugin/sign/sign.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package sign
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidSignature = errors.New("invalid signature")
|
||||||
|
ErrInvalidPublicKey = errors.New("invalid public key")
|
||||||
|
ErrInvalidPrivateKey = errors.New("invalid private key")
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyPair Ed25519 密钥对
|
||||||
|
type KeyPair struct {
|
||||||
|
PublicKey ed25519.PublicKey
|
||||||
|
PrivateKey ed25519.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateKeyPair 生成新的密钥对
|
||||||
|
func GenerateKeyPair() (*KeyPair, error) {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate key: %w", err)
|
||||||
|
}
|
||||||
|
return &KeyPair{PublicKey: pub, PrivateKey: priv}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign 使用私钥签名数据
|
||||||
|
func Sign(privateKey ed25519.PrivateKey, data []byte) []byte {
|
||||||
|
return ed25519.Sign(privateKey, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 使用公钥验证签名
|
||||||
|
func Verify(publicKey ed25519.PublicKey, data, signature []byte) bool {
|
||||||
|
return ed25519.Verify(publicKey, data, signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignBase64 签名并返回 Base64 编码
|
||||||
|
func SignBase64(privateKey ed25519.PrivateKey, data []byte) string {
|
||||||
|
sig := Sign(privateKey, data)
|
||||||
|
return base64.StdEncoding.EncodeToString(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyBase64 验证 Base64 编码的签名
|
||||||
|
func VerifyBase64(publicKey ed25519.PublicKey, data []byte, sigB64 string) error {
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(sigB64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode signature: %w", err)
|
||||||
|
}
|
||||||
|
if !Verify(publicKey, data, sig) {
|
||||||
|
return ErrInvalidSignature
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodePublicKey 编码公钥为 Base64
|
||||||
|
func EncodePublicKey(pub ed25519.PublicKey) string {
|
||||||
|
return base64.StdEncoding.EncodeToString(pub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodePublicKey 从 Base64 解码公钥
|
||||||
|
func DecodePublicKey(s string) (ed25519.PublicKey, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(data) != ed25519.PublicKeySize {
|
||||||
|
return nil, ErrInvalidPublicKey
|
||||||
|
}
|
||||||
|
return ed25519.PublicKey(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodePrivateKey 编码私钥为 Base64
|
||||||
|
func EncodePrivateKey(priv ed25519.PrivateKey) string {
|
||||||
|
return base64.StdEncoding.EncodeToString(priv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodePrivateKey 从 Base64 解码私钥
|
||||||
|
func DecodePrivateKey(s string) (ed25519.PrivateKey, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(data) != ed25519.PrivateKeySize {
|
||||||
|
return nil, ErrInvalidPrivateKey
|
||||||
|
}
|
||||||
|
return ed25519.PrivateKey(data), nil
|
||||||
|
}
|
||||||
47
pkg/plugin/sign/version.go
Normal file
47
pkg/plugin/sign/version.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package sign
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompareVersions 比较两个版本号
|
||||||
|
// 返回: -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
|
||||||
|
func CompareVersions(v1, v2 string) int {
|
||||||
|
parts1 := parseVersion(v1)
|
||||||
|
parts2 := parseVersion(v2)
|
||||||
|
|
||||||
|
maxLen := len(parts1)
|
||||||
|
if len(parts2) > maxLen {
|
||||||
|
maxLen = len(parts2)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < maxLen; i++ {
|
||||||
|
var p1, p2 int
|
||||||
|
if i < len(parts1) {
|
||||||
|
p1 = parts1[i]
|
||||||
|
}
|
||||||
|
if i < len(parts2) {
|
||||||
|
p2 = parts2[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if p1 < p2 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if p1 > p2 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVersion(v string) []int {
|
||||||
|
v = strings.TrimPrefix(v, "v")
|
||||||
|
parts := strings.Split(v, ".")
|
||||||
|
result := make([]int, len(parts))
|
||||||
|
for i, p := range parts {
|
||||||
|
n, _ := strconv.Atoi(p)
|
||||||
|
result[i] = n
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -212,6 +212,7 @@ type ClientPluginConnRequest struct {
|
|||||||
type JSPluginInstallRequest struct {
|
type JSPluginInstallRequest struct {
|
||||||
PluginName string `json:"plugin_name"` // 插件名称
|
PluginName string `json:"plugin_name"` // 插件名称
|
||||||
Source string `json:"source"` // JS 源码
|
Source string `json:"source"` // JS 源码
|
||||||
|
Signature string `json:"signature"` // 官方签名 (Base64)
|
||||||
RuleName string `json:"rule_name"` // 规则名称
|
RuleName string `json:"rule_name"` // 规则名称
|
||||||
RemotePort int `json:"remote_port"` // 服务端监听端口
|
RemotePort int `json:"remote_port"` // 服务端监听端口
|
||||||
Config map[string]string `json:"config"` // 插件配置
|
Config map[string]string `json:"config"` // 插件配置
|
||||||
|
|||||||
Reference in New Issue
Block a user