mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据:
1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。
本项目版本号**只由 `git describe --tags` 推导**(Makefile VERSION),
仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。
2. 逐项核对确认 **main 是 release 的内容严格子集**:
- `main` 独有的**文件**:0 个;
- `internal/lua/plugins.go` / `internal/core/kernel_actions.go` 等内核文件
main 独有行数:0;
- `deploy.sh` 那 14 行只是 emoji 清理(release 刻意去掉了 emoji);
- `channels.lua` 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。
- main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在**
(`git cherry` 逐条列出均为 `-`,即已合入)。
3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号",
且不会丢任何 main 独有的修复。
本次带回 v2.0.12 的全部内容(37 个提交):
- 源串行 + 按模型节流(照 model-gate)
- 已失效模型存储 + 远端目录闸门
- 余额分档选号
- 模型能力元数据(models.dev,isFree 权威判定)
- **凭据续期修复**(此前恒报 ok:true 却一个请求都不发)
- 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正
- 内核级:出站授权持久化重启后不生效、`__llmsproxy_plugin_dir` ground truth
校验:`git diff release/v2.0.x` = 0 行;`go build -tags luajit ./...` 通过。
228 lines
6.9 KiB
Go
228 lines
6.9 KiB
Go
package lua
|
||
|
||
import (
|
||
"crypto/aes"
|
||
"crypto/cipher"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
)
|
||
|
||
// ---- 测试用加密桩:与 config.SecretBox 同语义(AEAD + enc:v1 前缀)----
|
||
|
||
const testEncPrefix = "enc:v1:"
|
||
|
||
// realBox:真 AEAD-GCM(与 config.SecretBox 同原语),行为一致。
|
||
type realBox struct {
|
||
aead cipher.AEAD
|
||
}
|
||
|
||
func newRealBox(t *testing.T) *realBox {
|
||
t.Helper()
|
||
digest := sha256.Sum256([]byte("test-master-key"))
|
||
block, err := aes.NewCipher(digest[:])
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
aead, err := cipher.NewGCM(block)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return &realBox{aead: aead}
|
||
}
|
||
|
||
func (b *realBox) Encrypt(plain string) (string, error) {
|
||
nonce := make([]byte, b.aead.NonceSize())
|
||
if _, err := rand.Read(nonce); err != nil {
|
||
return "", err
|
||
}
|
||
sealed := b.aead.Seal(nonce, nonce, []byte(plain), nil)
|
||
return testEncPrefix + b64RawURL(sealed), nil
|
||
}
|
||
|
||
func (b *realBox) Decrypt(v string) (string, error) {
|
||
if !strings.HasPrefix(v, testEncPrefix) {
|
||
return v, nil
|
||
}
|
||
raw, err := b64RawURLDecode(strings.TrimPrefix(v, testEncPrefix))
|
||
if err != nil {
|
||
return v, err
|
||
}
|
||
n := b.aead.NonceSize()
|
||
if len(raw) < n {
|
||
return v, os.ErrInvalid
|
||
}
|
||
plain, err := b.aead.Open(nil, raw[:n], raw[n:], nil)
|
||
if err != nil {
|
||
return v, err
|
||
}
|
||
return string(plain), nil
|
||
}
|
||
|
||
// failBox 永远解密失败(模拟 master.key 被换/丢失)。
|
||
type failBox struct{}
|
||
|
||
func (failBox) Encrypt(plain string) (string, error) { return testEncPrefix + "AAAA", nil }
|
||
func (failBox) Decrypt(v string) (string, error) {
|
||
if !strings.HasPrefix(v, testEncPrefix) {
|
||
return v, nil
|
||
}
|
||
return v, os.ErrPermission
|
||
}
|
||
|
||
func b64RawURL(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||
func b64RawURLDecode(s string) ([]byte, error) {
|
||
return base64.RawURLEncoding.DecodeString(s)
|
||
}
|
||
|
||
// TestSecretVaultEncryptedAtRest 验证保险库落盘是密文(enc:v1 前缀),
|
||
// 且读回时解密为原值。明文 token 绝不能出现在磁盘字节里。
|
||
func TestSecretVaultEncryptedAtRest(t *testing.T) {
|
||
dir := t.TempDir()
|
||
ps := NewPlugins(nil, dir)
|
||
ps.SetSecretBox(newRealBox(t))
|
||
|
||
if err := ps.SetSecret("channels", "channels:minimax", "SUPER-TOKEN-123"); err != nil {
|
||
t.Fatalf("SetSecret: %v", err)
|
||
}
|
||
|
||
// 磁盘字节必须不含明文
|
||
raw, err := os.ReadFile(filepath.Join(dir, ".secrets", "channels.json"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if strings.Contains(string(raw), "SUPER-TOKEN-123") {
|
||
t.Fatalf("保险库明文落盘! 文件内容含 token:\n%s", raw)
|
||
}
|
||
if !strings.Contains(string(raw), testEncPrefix) {
|
||
t.Fatalf("落盘值应带 %s 前缀(统一密文形态):\n%s", testEncPrefix, raw)
|
||
}
|
||
|
||
// 读回是原值
|
||
v, ok, err := ps.GetSecret("channels", "channels:minimax")
|
||
if err != nil || !ok || v != "SUPER-TOKEN-123" {
|
||
t.Fatalf("round-trip 失败: v=%q ok=%v err=%v", v, ok, err)
|
||
}
|
||
|
||
// 文件权限 0600、目录 0700
|
||
if fi, err := os.Stat(filepath.Join(dir, ".secrets", "channels.json")); err != nil || fi.Mode().Perm() != 0o600 {
|
||
t.Fatalf("vault 文件权限应 0600, 实得 %v (err=%v)", fi.Mode(), err)
|
||
}
|
||
if fi, err := os.Stat(filepath.Join(dir, ".secrets")); err != nil || fi.Mode().Perm() != 0o700 {
|
||
t.Fatalf("vault 目录权限应 0700, 实得 %v (err=%v)", fi.Mode(), err)
|
||
}
|
||
}
|
||
|
||
// TestSecretVaultPlaintextFallbackAndUpgrade:旧明文库可读(向后兼容),
|
||
// 且下一次写会整体升级为密文。
|
||
func TestSecretVaultPlaintextFallbackAndUpgrade(t *testing.T) {
|
||
dir := t.TempDir()
|
||
ps := NewPlugins(nil, dir)
|
||
// 1) 无 box 时写入 = 旧版本形态(明文)
|
||
if err := ps.SetSecret("channels", "k", "OLD-PLAIN"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 2) 换上有 box 的新版本:旧明文必须能读出来
|
||
ps.SetSecretBox(newRealBox(t))
|
||
v, ok, err := ps.GetSecret("channels", "k")
|
||
if err != nil || !ok || v != "OLD-PLAIN" {
|
||
t.Fatalf("旧明文读回应原值: v=%q ok=%v err=%v", v, ok, err)
|
||
}
|
||
// 3) 任意一次写(哪怕写别的键)会把整个文件升级为密文
|
||
if err := ps.SetSecret("channels", "k2", "NEW"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
raw, _ := os.ReadFile(filepath.Join(dir, ".secrets", "channels.json"))
|
||
if strings.Contains(string(raw), "OLD-PLAIN") {
|
||
t.Fatalf("升级写后旧明文值仍在盘上:\n%s", raw)
|
||
}
|
||
var m map[string]string
|
||
if err := json.Unmarshal(raw, &m); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.HasPrefix(m["k"], testEncPrefix) {
|
||
t.Fatalf("旧值应被重封为密文: %v", m["k"])
|
||
}
|
||
// 4) 两个值都还能读回
|
||
for key, want := range map[string]string{"k": "OLD-PLAIN", "k2": "NEW"} {
|
||
if v, _, _ := ps.GetSecret("channels", key); v != want {
|
||
t.Fatalf("升级后 %q: got %q want %q", key, v, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestSecretVaultWrongMasterKey:master.key 换掉后读库必须**报错**而不是静默
|
||
// 返回空 —— 空会让插件重新登录并覆盖文件,把可恢复问题变成凭据丢失。
|
||
func TestSecretVaultWrongMasterKey(t *testing.T) {
|
||
dir := t.TempDir()
|
||
ps := NewPlugins(nil, dir)
|
||
ps.SetSecretBox(newRealBox(t))
|
||
if err := ps.SetSecret("channels", "k", "SECRET"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 换一把钥匙(模拟 master.key 丢失重建)
|
||
ps.SetSecretBox(failBox{})
|
||
if _, _, err := ps.GetSecret("channels", "k"); err == nil {
|
||
t.Fatal("换 master key 后读库未报错 —— 会静默吞凭据")
|
||
}
|
||
}
|
||
|
||
// ---- 并发压力:多插件同时读写各自的库 ----
|
||
|
||
// TestSecretVaultConcurrentPlugins 压力:8 个插件 × 每插件 2 goroutine 并发
|
||
// set/get/delete 自己的键。断言:无数据串台(跨插件不可见)、无丢写、文件数正确。
|
||
func TestSecretVaultConcurrentPlugins(t *testing.T) {
|
||
dir := t.TempDir()
|
||
ps := NewPlugins(nil, dir)
|
||
ps.SetSecretBox(newRealBox(t))
|
||
|
||
const plugins = 8
|
||
const perG = 50
|
||
var wg sync.WaitGroup
|
||
for p := 0; p < plugins; p++ {
|
||
name := "plug" + string(rune('A'+p))
|
||
for g := 0; g < 2; g++ {
|
||
wg.Add(1)
|
||
go func(name string, g int) {
|
||
defer wg.Done()
|
||
for i := 0; i < perG; i++ {
|
||
key := name + ":k" + string(rune('a'+g))
|
||
if err := ps.SetSecret(name, key, name+"-token"); err != nil {
|
||
t.Errorf("SetSecret(%s,%s): %v", name, key, err)
|
||
return
|
||
}
|
||
v, ok, err := ps.GetSecret(name, key)
|
||
if err != nil || !ok || v != name+"-token" {
|
||
t.Errorf("GetSecret(%s,%s)=%q ok=%v err=%v", name, key, v, ok, err)
|
||
return
|
||
}
|
||
// 跨插件必须不可见
|
||
other := "plug" + string(rune('A'+(p+1)%plugins))
|
||
if _, ok, _ := ps.GetSecret(other, key); ok {
|
||
t.Errorf("凭据串台: %s 的键在 %s 可见", name, other)
|
||
return
|
||
}
|
||
if g == 1 && i%10 == 9 {
|
||
_ = ps.DeleteSecret(name, key)
|
||
}
|
||
}
|
||
}(name, g)
|
||
}
|
||
}
|
||
wg.Wait()
|
||
|
||
entries, err := os.ReadDir(filepath.Join(dir, ".secrets"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(entries) > plugins {
|
||
t.Errorf("vault 文件数 %d 超过插件数 %d(应一插件一文件)", len(entries), plugins)
|
||
}
|
||
}
|