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

This commit is contained in:
Flik
2025-12-29 19:23:09 +08:00
parent 4116d8934c
commit ab81e08100
16 changed files with 846 additions and 36 deletions

View File

@@ -20,6 +20,7 @@ type JSPlugin struct {
vm *goja.Runtime
metadata plugin.Metadata
config map[string]string
sandbox *Sandbox
running bool
mu sync.Mutex
}
@@ -27,9 +28,10 @@ type JSPlugin struct {
// NewJSPlugin 从 JS 源码创建插件
func NewJSPlugin(name, source string) (*JSPlugin, error) {
p := &JSPlugin{
name: name,
source: source,
vm: goja.New(),
name: name,
source: source,
vm: goja.New(),
sandbox: DefaultSandbox(),
}
if err := p.init(); err != nil {
@@ -39,6 +41,11 @@ func NewJSPlugin(name, source string) (*JSPlugin, error) {
return p, nil
}
// SetSandbox 设置沙箱配置
func (p *JSPlugin) SetSandbox(sandbox *Sandbox) {
p.sandbox = sandbox
}
// init 初始化 JS 运行时
func (p *JSPlugin) init() error {
// 注入基础 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)
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 {
return os.WriteFile(path, []byte(content), 0644) == nil
func (p *JSPlugin) fsWriteFile(path, content string) map[string]interface{} {
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)
if err != nil {
return nil
return map[string]interface{}{"error": err.Error(), "entries": nil}
}
var result []map[string]interface{}
for _, e := range entries {
@@ -257,15 +292,20 @@ func (p *JSPlugin) fsReadDir(path string) []map[string]interface{} {
"size": info.Size(),
})
}
return result
return map[string]interface{}{"error": "", "entries": result}
}
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)
if err != nil {
return nil
return map[string]interface{}{"error": err.Error()}
}
return map[string]interface{}{
"error": "",
"name": info.Name(),
"size": info.Size(),
"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)
return err == nil
return map[string]interface{}{"error": "", "exists": err == nil}
}
func (p *JSPlugin) fsMkdir(path string) bool {
return os.MkdirAll(path, 0755) == nil
func (p *JSPlugin) fsMkdir(path string) map[string]interface{} {
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 {
return os.RemoveAll(path) == nil
func (p *JSPlugin) fsRemove(path string) map[string]interface{} {
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}
}
// =============================================================================

View 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
}

View 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
View 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
}

View 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
View 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
}

View 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
}

View File

@@ -212,6 +212,7 @@ type ClientPluginConnRequest struct {
type JSPluginInstallRequest struct {
PluginName string `json:"plugin_name"` // 插件名称
Source string `json:"source"` // JS 源码
Signature string `json:"signature"` // 官方签名 (Base64)
RuleName string `json:"rule_name"` // 规则名称
RemotePort int `json:"remote_port"` // 服务端监听端口
Config map[string]string `json:"config"` // 插件配置