mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix: 回退两处内核改动,channels 改为插件自持持久化(照参考实现)
## 回退的内核改动
用户指出「你又tm改内核?」—— 核对后确认两处本可避免:
1. internal/lua/vm.go +32 行 新增 base64_decode 全局函数
2. internal/lua/plugin_http_handler.go +16 行 InvokeHandler 补 markDirty
工作区已还原到 v2.0.2(git diff v2.0.2 = 0 行)。
## 为什么两处都是错的
参考实现(dsh-our-free-model/vendor/channel-pack)对同一问题的做法:
- 解 JWT:用**语言自带**的 `Buffer.from(..., 'base64url')`,从不为此改宿主。
→ 我改成了插件的纯 Lua b64url_decode(约 20 行),实测解出真 JWT 的
nickname/sub/exp 全对。
- 状态持久化:插件**自己 writeFileSync+renameSync** 写
`channel-pack/state.json`,从不依赖宿主 settings。
→ 参考还记下了同构教训(Gitee IKI7WT):DSH 0.1.7 移除 settings.register()
后账号列表退化为纯内存;正解是**插件探测宿主能力、自持文档**,不是要求
宿主改。我的 markDirty 修法方向相反:把插件的数据安全外包给宿主某条
分发路径的实现细节。
## channels 改为插件自持存储
- `.channels-store.json`(tmp + rename 原子替换),只放**索引与配置**
(账号条目含 credentialRef 而非凭据本身、模型黑名单、设置、事件日志)。
凭据仍在 .secrets/(0600),两者分离:即便文档泄露也不含 token。
- 变更点同步落盘(不用「置标志+定时 flush」):本插件无 hooks、45 个端点
各自变更状态,还有大量内部互调;挂在某个边界就必须保证**每条**路径都
经过它,漏一条就是「操作成功但重启丢失」——正是本次要修的缺陷形态。
状态实测 ~1.7KB,写入只发生在运维动作上,不在请求热路径。
- 黑名单写入收口到 setModelDisabled(写点曾有 5+ 处,散着写必漏)。
- 存量迁移:读内核遗留 `.channels.state.json`(v2.0.4 内核写的)→ 写自有
文档 → **写成功才删**遗留(写失败还删=用一次迁移丢数据);自有存储已存在
时也删遗留,否则内核 restore() 会在启动时用它覆盖。
## 顺带修了两个真实缺陷
1. **测试污染生产目录**(严重):插件 storeDir() 读 /etc/llmsproxy/config.yaml
决定落盘位置,而既有测试都没隔离 → 一次全量测试把 26 个测试账号写进了
**生产** .channels-store.json(含 fixture-a/example 等夹具 id)。
prod 文件已删除(真实账号另有来源);kernelTestCore 现在隔离插件目录,
且尊重调用方已设的值(多次「重启」需共用同一目录)。
2. model_set_disabled 改了写入口后仍引用已删除的局部变量(nil 索引崩溃)。
## 判据(变异全部咬人)
- TestChannelsStoreIsPluginOwned:状态写进**插件自己的**文件、不含凭据字段、
新实例读回。变异(markDirty 空操作)→ FAIL。
- TestChannelsMigrationKeepsLegacyOnWriteFailure:**写失败必须保留遗留文件**。
变异(无条件删)→ FAIL。(这条单独测:迁移成功后的删除有两条路径互相掩盖,
只测「最终文件没了」区分不出「写失败也删了」。)
- TestChannelsMigratesLegacyKernelState:遗留账号迁入内存 + 自有存储写出 +
遗留文件删除。
- 内核对照 v2.0.2:diff 为空。
go test ./internal/core/(78s)与 ./internal/lua/(63s)全绿;
全量测试后确认生产插件目录未被写入。
This commit is contained in:
@ -340,3 +340,99 @@ func TestInjectPlatformsRejectsBadInput(t *testing.T) {
|
||||
t.Fatalf("清空后平台数 = %d, 期望回到基线 %d", got, base)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsBrowserStackDeps 锁定浏览器栈的依赖预检能力。
|
||||
//
|
||||
// 这个能力此前**不存在**:代码里写着「运行 deploy 脚本一键安装」,但安装机制
|
||||
// 根本没有(deploy.sh 里零浏览器栈内容),于是 VNC 兜底在缺依赖的机器上只会
|
||||
// 报一句含糊的"进程启动失败"。现在如实报告缺什么、装哪个包。
|
||||
func TestChannelsBrowserStackDeps(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{
|
||||
Method: "GET", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deps_status: %v", err)
|
||||
}
|
||||
m, ok := out.JSON.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("deps_status 应返回对象: %#v", out.JSON)
|
||||
}
|
||||
if _, has := m["ok"]; !has {
|
||||
t.Fatal("deps_status 必须报告 ok")
|
||||
}
|
||||
if _, has := m["missing"]; !has {
|
||||
t.Fatal("deps_status 必须报告 missing 清单")
|
||||
}
|
||||
if m["ok"] == false {
|
||||
hint, _ := m["install_hint"].(string)
|
||||
if hint == "" {
|
||||
t.Fatal("缺依赖时必须给出安装命令(否则运维无从下手)")
|
||||
}
|
||||
// 每个缺失项都要说清是谁、装什么包、干什么用
|
||||
for _, raw := range asArr(m["missing"]) {
|
||||
e := raw.(map[string]interface{})
|
||||
for _, k := range []string{"bin", "pkg", "why"} {
|
||||
if s, _ := e[k].(string); s == "" {
|
||||
t.Fatalf("缺少字段 %s: %v", k, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("本机缺依赖(预期:CI 容器通常没有浏览器栈): %v", m["missing"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsBrowserEndpointsRequireSession 浏览器栈的三个端点在没有会话时
|
||||
// 必须**如实报错**,而不是假装成功或 panic。
|
||||
func TestChannelsBrowserEndpointsRequireSession(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 无会话时 collect 必须报错
|
||||
out, err := ps.InvokeHandler("channels", "collect_credentials", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("collect_credentials: %v", err)
|
||||
}
|
||||
if out.JSON.(map[string]interface{})["error"] == nil {
|
||||
t.Fatal("无浏览器会话时 collect_credentials 必须报错")
|
||||
}
|
||||
|
||||
// status 如实说 running=false
|
||||
out, err = ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
|
||||
Method: "GET", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("browser_status: %v", err)
|
||||
}
|
||||
if out.JSON.(map[string]interface{})["running"] != false {
|
||||
t.Fatal("无会话时 running 应为 false")
|
||||
}
|
||||
|
||||
// stop 幂等:无会话时也返回 ok
|
||||
out, err = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stop_browser: %v", err)
|
||||
}
|
||||
if out.JSON.(map[string]interface{})["ok"] != true {
|
||||
t.Fatal("stop_browser 应幂等成功")
|
||||
}
|
||||
}
|
||||
|
||||
222
internal/core/channels_store_test.go
Normal file
222
internal/core/channels_store_test.go
Normal file
@ -0,0 +1,222 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// TestChannelsStoreIsPluginOwned 是「插件自持持久化」的回归。
|
||||
//
|
||||
// 背景:本插件声明 hooks = {},全部状态变更走自己的 HTTP 路由。此前 v2.0.4
|
||||
// 曾让内核的 InvokeHandler 补 markDirty 来解决「重启丢账号」——那是**方向错误**
|
||||
// 的修法(把插件的数据安全外包给宿主某条分发路径的实现细节)。参考实现
|
||||
// (channel-pack-store.ts)踩过同一课:宿主 settings 能力一变,账号列表就退化
|
||||
// 为纯内存,正解是插件自持文档。
|
||||
//
|
||||
// 本测试断言:插件把状态写进**自己的**存储文件,且新实例能读回。
|
||||
func TestChannelsStoreIsPluginOwned(t *testing.T) {
|
||||
// 固定插件目录:两次「重启」必须共用同一目录,否则测不出持久化。
|
||||
// 这也是防止测试写进生产插件目录的那道隔离(kernelTestCore 会 respect 它)。
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", dir)
|
||||
load := func() *lua.Plugins {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "list_sources", "remove_source", "status"}, false)
|
||||
return ps
|
||||
}
|
||||
|
||||
ps := load()
|
||||
// 加账号 → 状态变更 → 应立即落盘
|
||||
add, err := ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy","nickname":"持久化测试"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
|
||||
// ① 插件自有存储文件必须存在
|
||||
storePath := filepath.Join(dir, ".channels-store.json")
|
||||
b, err := os.ReadFile(storePath)
|
||||
if err != nil {
|
||||
t.Fatalf("★ 插件自有存储未写出(%s): %v\n"+
|
||||
" —— 状态落盘必须由插件自己负责,不能依赖内核分发路径", storePath, err)
|
||||
}
|
||||
var saved struct {
|
||||
State map[string]interface{} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &saved); err != nil {
|
||||
t.Fatalf("存储文件不是合法 JSON: %v", err)
|
||||
}
|
||||
pool, _ := saved.State["pool"].(map[string]interface{})
|
||||
buddy, _ := pool["buddy"].(map[string]interface{})
|
||||
if _, ok := buddy[aid]; !ok {
|
||||
t.Fatalf("存储里应含账号 %s,实得 pool=%v", aid, pool)
|
||||
}
|
||||
|
||||
// ② 凭据绝不进这个文件(它在 .secrets/ 保险库,0600)
|
||||
if s, _ := saved.State["credentials"].(map[string]interface{}); len(s) > 0 {
|
||||
t.Fatal("自有存储不得包含凭据(凭据只进 .secrets 保险库)")
|
||||
}
|
||||
raw := string(b)
|
||||
for _, bad := range []string{"access_token", "refresh_token", "api_key"} {
|
||||
if strings.Contains(raw, bad) {
|
||||
t.Fatalf("自有存储疑似含凭据字段 %q —— 凭据与索引必须分离", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 新实例(模拟重启)必须读回账号
|
||||
ps2 := load()
|
||||
lst, err := ps2.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_list: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
|
||||
if raw.(map[string]interface{})["id"] == aid {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("★ 重启后账号 %s 未恢复 —— 持久化未生效", aid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsMigratesLegacyKernelState 回归:升级时把内核遗留文件
|
||||
// (.channels.state.json,v2.0.4 内核写的)迁移到自有存储并删除遗留文件。
|
||||
//
|
||||
// 迁移必须真的搬数据(生产上那份文件含真实账号池),且**写成功才删**遗留 ——
|
||||
// 写失败还删它,等于用一次迁移把数据弄丢。
|
||||
func TestChannelsMigratesLegacyKernelState(t *testing.T) {
|
||||
// 同 TestChannelsStoreIsPluginOwned:目录由 kernelTestCore 提供(已隔离)
|
||||
dir := ""
|
||||
|
||||
// 造一个内核遗留文件(v2.0.4 的形状)
|
||||
legacy := map[string]interface{}{
|
||||
"version": 1,
|
||||
"state": map[string]interface{}{
|
||||
"accounts": map[string]interface{}{},
|
||||
"log": []interface{}{},
|
||||
"pool": map[string]interface{}{
|
||||
"buddy": map[string]interface{}{
|
||||
"legacy-acct": map[string]interface{}{
|
||||
"id": "legacy-acct", "provider": "buddy", "nickname": "遗留账号",
|
||||
"enabled": true, "credentialRef": "channels:buddy:legacy-acct",
|
||||
"createdAt": 1, "last_claim": 0, "last_claim_status": 0, "last_claim_summary": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(legacy)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
dir = c.Config().PluginDir
|
||||
legacyPath := filepath.Join(dir, ".channels.state.json")
|
||||
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ① 遗留账号必须被迁移进内存(可见)
|
||||
lst, err := ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
|
||||
if raw.(map[string]interface{})["id"] == "legacy-acct" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("★ 遗留账号未迁移 —— 升级即丢账号")
|
||||
}
|
||||
|
||||
// ② 自有存储必须已写出
|
||||
if _, err := os.Stat(filepath.Join(dir, ".channels-store.json")); err != nil {
|
||||
t.Fatalf("迁移后自有存储应存在: %v", err)
|
||||
}
|
||||
// ③ 遗留文件必须被删除(否则内核 restore() 会用它覆盖自有存储)
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatal("★ 遗留内核文件应被删除,否则下次启动会被它覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsMigrationKeepsLegacyOnWriteFailure 锁定迁移的**数据安全**契约:
|
||||
// 写自有存储失败时**绝不能**删除遗留文件 —— 写失败还删它,等于用一次迁移把
|
||||
// 数据弄丢(与本次要修的「重启丢账号」是同类事故)。
|
||||
//
|
||||
// 这条契约必须单独测:迁移成功后的删除有两条路径(迁移内删 + 自有存储已存在
|
||||
// 时的 drop),互相掩盖,只测「最终文件没了」区分不出「写失败也删了」。
|
||||
func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) {
|
||||
// 强制写失败的手段:把 persistState 要写的临时文件路径
|
||||
// (<dir>/.channels-store.json.tmp)**占成一个目录**,io.open(tmp,"w") 必失败。
|
||||
// 不用 chmod:测试以 root 跑,root 绕过权限位,chmod 拦不住。
|
||||
sub := t.TempDir()
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", sub)
|
||||
|
||||
legacy := map[string]interface{}{
|
||||
"version": 1,
|
||||
"state": map[string]interface{}{
|
||||
"pool": map[string]interface{}{
|
||||
"buddy": map[string]interface{}{
|
||||
"keepme": map[string]interface{}{
|
||||
"id": "keepme", "provider": "buddy",
|
||||
"credentialRef": "channels:buddy:keepme", "createdAt": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(legacy)
|
||||
legacyPath := filepath.Join(sub, ".channels.state.json")
|
||||
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(sub, ".channels-store.json.tmp"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.LoadSource("channels", src)
|
||||
|
||||
// 写失败的态下,遗留文件必须原样保留
|
||||
if _, err := os.Stat(legacyPath); err != nil {
|
||||
t.Fatalf("★ 写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err)
|
||||
}
|
||||
// 且自有存储不应存在(写失败了)
|
||||
if _, err := os.Stat(filepath.Join(sub, ".channels-store.json")); err == nil {
|
||||
t.Fatal("tmp 路径被占住时不应写出自有存储")
|
||||
}
|
||||
}
|
||||
@ -20,10 +20,19 @@ import (
|
||||
func kernelTestCore(t *testing.T) *Core {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
// 插件的自有存储位置由插件自己决定(读 config.yaml 的 plugin_dir,或
|
||||
// LLMSPROXY_PLUGIN_DIR 覆盖)。测试**必须**指向临时目录 —— 否则插件会读到
|
||||
// /etc/llmsproxy/config.yaml 并写进**生产插件目录**(真实事故:一次全量测试
|
||||
// 把 26 个测试账号写进了生产 .channels-store.json)。
|
||||
// 调用方已显式设定时尊重它:多次「重启」要共用同一目录才能测持久化。
|
||||
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
PluginDir: filepath.Join(dir, "plugins"),
|
||||
PluginDir: pluginDir,
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-kernel-0001"},
|
||||
|
||||
Reference in New Issue
Block a user