mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户报"完全不可用"。逐平台查 account/list,发现 13 个账号里只有 1 个有凭据,
其余是占位壳子 —— 再查发现**账号池被清空了**,而保险库里凭据还在
(症状正是"账号凭空消失")。本提交修的是这个数据事故的根因。
## 根因(全程实测,非推断)
channels 插件用 `storeDir()` 决定自己的存储位置,它有多级兜底:
env → settings → **读 /etc/llmsproxy/config.yaml 的 plugin_dir** → 硬编码。
测试台没设 `LLMSPROXY_PLUGIN_DIR` 时,后两级直接命中**生产**:
测试插件目录 = /tmp/TestXXX/002/plugins
storeDir 结果 = /etc/llmsproxy/plugins ← 实测输出
于是测试加载真 channels.lua 时**读的是生产存储、写的也是生产存储**。
一次全量 `go test ./internal/lua` 就把
`/etc/llmsproxy/plugins/.channels-store.json` 从 1730 覆盖成 1986 字节、
账号池清空(inode 都换了)。
同类前科:曾把 26 个测试账号写进生产 store。两次同一类错误。
## 修法(两层,第二层才是根治)
1. **内核把插件的真实目录交给插件**(ground truth,不再靠猜):
新增 `__llmsproxy_plugin_dir` 全局,在 bootPluginState 里注册。
`storeDir()` 优先用它 —— 这样**即使没有环境变量**也不会落到生产路径。
实测:撤掉 env 后 storeDir 仍落在临时目录。
2. 测试台补隔离:`runtimePlugins` 设 env + `DisableStatePersistence()`
(插件声明了 schedule 时内核会自动起调度器,**后台 goroutine** 比
`t.Setenv` 生命周期长,测试结束后仍可能落盘)。
## 判据 + 变异验证
`internal/lua/store_dir_isolation_test.go`:
- storeDir 绝不能是 /etc/llmsproxy 前缀
- 必须落在临时目录
- env 必须真的被设上(机制而非巧合)
**变异验证**:把隔离设成生产路径 → 判据变红并打印
「storeDir 指向了**生产**目录」;恢复 → 变绿。
全量回归:9 个包全绿,且 `go test ./...` 后生产存储**未被改动**(此前必被改)。
286 lines
8.5 KiB
Go
286 lines
8.5 KiB
Go
package lua
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
|
||
golua "github.com/aarzilli/golua/lua"
|
||
)
|
||
|
||
// Package lua — Lua 绑定:凭据保险库 + 内核动作面
|
||
//
|
||
// 这两个全局把"插件自己主导链接认证"所需的两块补齐:
|
||
//
|
||
// __llmsproxy_secrets —— 安全存放 OAuth token / session / 设备指纹
|
||
// __llmsproxy_kernel —— 触发内核动作(注册源、探测、清冷却、改链路…)
|
||
//
|
||
// 注册点与 __llmsproxy_http 相同:只在 PLUGIN 的 bootPluginState 里装,
|
||
// 因为适配器共用 setupGlobals 而不该拿到这些能力。
|
||
//
|
||
// 两张表都用 buildJSONTable 的既有写法(NewTable + PushGoClosure + SetField),
|
||
// 该写法在 json 表上长期稳定,也是内核自己的用法。
|
||
|
||
// secretGlobal 是凭据保险库的 Lua 名字。
|
||
const secretGlobal = "__llmsproxy_secrets"
|
||
|
||
// pluginDirGlobal carries the plugin's OWN directory as ground truth.
|
||
//
|
||
// Why it must come from the kernel (live defect): the channels plugin decides
|
||
// where to keep its state by reading /etc/llmsproxy/config.yaml when
|
||
// LLMSPROXY_PLUGIN_DIR is unset. Tests load the real plugin but never set that
|
||
// variable, so they read AND WROTE the production store — one full
|
||
// `go test ./internal/lua` replaced the production account pool with the test's
|
||
// empty one (13 accounts → 0; the credentials stayed in the vault, so the
|
||
// symptom was "accounts vanished"). The kernel already knows the true directory
|
||
// (ps.dir), so handing it over removes the guess entirely: a plugin no longer
|
||
// has to infer its own location from a config file it might not own.
|
||
const pluginDirGlobal = "__llmsproxy_plugin_dir"
|
||
|
||
// registerPluginDirBridge installs the plugin's own directory as a plain string
|
||
// global. Deliberately a string, not a table: there is nothing to call, and a
|
||
// bare value cannot be shadowed by a later SetField on a shared table.
|
||
func registerPluginDirBridge(L *golua.State, dir string) {
|
||
L.PushString(dir)
|
||
L.SetGlobal(pluginDirGlobal)
|
||
}
|
||
|
||
// kernelGlobal 是内核动作面的 Lua 名字。
|
||
const kernelGlobal = "__llmsproxy_kernel"
|
||
|
||
// registerSecretBridge installs the credential vault onto a plugin's state.
|
||
//
|
||
// The plugin name is bound HERE, at boot, rather than read from the Lua state at
|
||
// call time. That is what makes "a plugin can only read its OWN credentials" a
|
||
// mechanism instead of a convention: a shard or any other holder of the compiled
|
||
// script cannot pass a different name.
|
||
func registerSecretBridge(L *golua.State, ps *Plugins, name string) {
|
||
L.NewTable()
|
||
L.PushGoClosure(luaSecretSet(ps, name))
|
||
L.SetField(-2, "set")
|
||
L.PushGoClosure(luaSecretGet(ps, name))
|
||
L.SetField(-2, "get")
|
||
L.PushGoClosure(luaSecretDelete(ps, name))
|
||
L.SetField(-2, "delete")
|
||
L.PushGoClosure(luaSecretKeys(ps, name))
|
||
L.SetField(-2, "keys")
|
||
L.SetGlobal(secretGlobal)
|
||
}
|
||
|
||
// luaSecretSet: set(key, value) -> (true, nil) | (nil, err)
|
||
func luaSecretSet(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
key := L.ToString(1)
|
||
val := L.ToString(2)
|
||
if err := ps.SetSecret(name, key, val); err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
L.PushBoolean(true)
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaSecretGet: get(key) -> (value, nil) | (nil, err). A missing key yields
|
||
// (nil, nil) so a plugin can test `if tok == nil then login() end` — the "no
|
||
// credential stored yet" case is the common one for a fresh install, and making
|
||
// it an error would force every caller to string-match on a message.
|
||
func luaSecretGet(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
key := L.ToString(1)
|
||
v, ok, err := ps.GetSecret(name, key)
|
||
if err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
if !ok {
|
||
L.PushNil()
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
L.PushString(v)
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaSecretDelete: delete(key) -> (true, nil) | (nil, err)
|
||
func luaSecretDelete(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
key := L.ToString(1)
|
||
if err := ps.DeleteSecret(name, key); err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
L.PushBoolean(true)
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaSecretKeys: keys() -> (array of key names, nil). Names only — a plugin can
|
||
// render "which accounts are configured" without ever echoing token bytes.
|
||
func luaSecretKeys(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
keys, err := ps.SecretKeys(name)
|
||
if err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
L.NewTable()
|
||
for i, k := range keys {
|
||
L.PushString(k)
|
||
L.RawSeti(-2, i+1)
|
||
}
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// registerKernelBridge installs the kernel action surface onto a plugin's state.
|
||
func registerKernelBridge(L *golua.State, ps *Plugins, name string) {
|
||
L.NewTable()
|
||
L.PushGoClosure(luaKernelActions(ps, name))
|
||
L.SetField(-2, "actions")
|
||
L.PushGoClosure(luaKernelCall(ps, name))
|
||
L.SetField(-2, "call")
|
||
L.SetGlobal(kernelGlobal)
|
||
}
|
||
|
||
// luaKernelActions: actions() -> (array of {name,title,danger,description,params,
|
||
// granted}, nil)
|
||
//
|
||
// It reports GRANTED alongside each action so a plugin can degrade gracefully
|
||
// (skip the part that needs a permission it does not have) instead of
|
||
// discovering it by hitting an error mid-operation.
|
||
func luaKernelActions(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
perm := ps.PluginKernelPermission(name)
|
||
L.NewTable()
|
||
for i, a := range KernelActions() {
|
||
L.NewTable()
|
||
L.PushString(a.Name)
|
||
L.SetField(-2, "name")
|
||
L.PushString(a.Title)
|
||
L.SetField(-2, "title")
|
||
L.PushString(a.Danger)
|
||
L.SetField(-2, "danger")
|
||
L.PushString(a.Description)
|
||
L.SetField(-2, "description")
|
||
L.PushBoolean(perm.allows(a))
|
||
L.SetField(-2, "granted")
|
||
L.NewTable()
|
||
for j, p := range a.Params {
|
||
L.PushString(p)
|
||
L.RawSeti(-2, j+1)
|
||
}
|
||
L.SetField(-2, "params")
|
||
L.RawSeti(-2, i+1)
|
||
}
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaKernelCall: call(name, args) -> (result, nil) | (nil, err)
|
||
//
|
||
// One entry point rather than a function per action: the action set will change,
|
||
// and a table field per action would mean editing bootPluginState — the exact
|
||
// place that produced a cgo crash during earlier work.
|
||
func luaKernelCall(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
action := L.ToString(1)
|
||
args := map[string]interface{}{}
|
||
if L.Type(2) == golua.LUA_TTABLE {
|
||
var raw interface{}
|
||
if err := luaToJSON(L, 2, &raw); err == nil {
|
||
if m, ok := raw.(map[string]interface{}); ok {
|
||
args = m
|
||
}
|
||
}
|
||
}
|
||
v, err := ps.callKernel(name, action, args)
|
||
if err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
if v == nil {
|
||
L.PushNil()
|
||
} else {
|
||
pushGoValue(L, v)
|
||
}
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// readPluginKernelDeclared reads `plugin.kernel`, the actions a plugin asks for.
|
||
//
|
||
// The shape is a list of names:
|
||
//
|
||
// plugin.kernel = { "status", "reset_health" }
|
||
//
|
||
// Unknown names are kept rather than dropped so the settings page can show a
|
||
// typo as "requested but not a real action" — silently discarding it would make
|
||
// a plugin's intent invisible at exactly the moment the operator is deciding
|
||
// what to grant.
|
||
func readPluginKernelDeclared(L *golua.State) []string {
|
||
raw, ok := pluginFieldValue(L, "kernel")
|
||
if !ok {
|
||
return nil
|
||
}
|
||
items, isList := raw.([]interface{})
|
||
if !isList {
|
||
if one, isTable := raw.(map[string]interface{}); isTable {
|
||
// A single string field or a set-style table; accept both spellings.
|
||
if v, ok := one["actions"].([]interface{}); ok {
|
||
items = v
|
||
} else {
|
||
return nil
|
||
}
|
||
} else {
|
||
return nil
|
||
}
|
||
}
|
||
out := make([]string, 0, len(items))
|
||
seen := map[string]bool{}
|
||
for _, it := range items {
|
||
name, ok := it.(string)
|
||
if !ok {
|
||
continue
|
||
}
|
||
name = strings.TrimSpace(name)
|
||
if name == "" || seen[name] {
|
||
continue
|
||
}
|
||
seen[name] = true
|
||
out = append(out, name)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// luaTableArg decodes a Lua table argument into a Go map, reporting a clear error
|
||
// when the caller passed something else (a common mistake that would otherwise
|
||
// surface as a silent zero value).
|
||
func luaTableArg(L *golua.State, idx int) (map[string]interface{}, error) {
|
||
if L.Type(idx) != golua.LUA_TTABLE {
|
||
return nil, fmt.Errorf("expected a table argument, got %s", normalizeString(L, idx))
|
||
}
|
||
var raw interface{}
|
||
if err := luaToJSON(L, idx, &raw); err != nil {
|
||
return nil, err
|
||
}
|
||
m, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
return nil, fmt.Errorf("argument must be a keyed table")
|
||
}
|
||
return m, nil
|
||
}
|