mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
feat(lua): 插件可调内核动作面(R4-c)+ 凭据保险库(R4-d)
用户指出「接口不全」——此前插件只有 __llmsproxy_adapters 三个手写闭包,
够不到内核。对照参考插件(dsh-our-free-model,13 个账号渠道)的真实能力,
缺的正是这两块:
插件自己驱动链接认证 → 存凭据 → 定时领额度 → 把源接进内核
## R4-c 内核动作面:__llmsproxy_kernel
18 个动作,全部映射到既有 Core 方法(不是新造能力):
只读 6:status / list_sources / list_models / list_adapters /
list_auto_rules / adapter_owner
写 8:reload / reset_health / probe_all / save_auto_rules /
save_key_auto / add_source / create_key / update_key
破坏性 4:remove_source / delete_key / remove_adapter / set_plugin_enabled
**add_source 是「插件注册自己的上游」的落点**——插件拿到凭据后可以自己把
端点接进网关,无需运维改 config.yaml。已端到端验证:插件调它 → 内核源列表
出现 → 模型进入可路由列表。
依赖方向:lua 不能 import core,所以接口定义在 lua、实现放 core
(kernel_actions.go),走 SetKernelAPI 注入——与 SetOutboundHosts 同一先例。
**凭据边界**:动作面**有意排除** list_keys/find_key。Core.ListKeys() 返回
明文网关密钥,交给第三方 Lua 等于交出整个部署的凭据面。有回归测试锁死
这条(TestKernelActionSurfaceExcludesPlaintextKeys)。list_sources 返回的
api_key 也强制掩码(只给 ***+后4位)。
**权限**(用户选「全开+设置页可配」):18 个动作全部可达,但**默认拒绝**,
授权落盘 .kernel-perms.json(独立文件,不并进 .access.json——后者是
map[string]string,塞进去会破坏旧二进制能读的格式)。danger 级不接受通配符。
插件可在源码 plugin.kernel={...} **申请**,设置页显示申请 vs 已授予。
**Go 侧 deadline**(用户选定):每个动作有超时预算,超时把 (nil,err) 还给
Lua。这可行的原因:Lua 只是等返回值,不占 Lua 栈执行权——与「Lua 死循环
不可中断」是两件事。
## R4-d 凭据保险库:__llmsproxy_secrets
此前插件存 token 只能写 plugin.state:0755 目录、明文、与计费累计量同文件、
会被 PUT /state 整体替换、会进 WebUI。现在独立文件:
<plugin_dir>/.secrets/<plugin>.json 文件 0600,目录 0700
临时文件**先 chmod 再 rename**,全程不存在宽权限窗口。读取身份在插件 boot
时绑定(不从 Lua 状态现读)⇒ 跨插件读不可行是机制而非约定。插件删除时清空
(DeleteAllSecrets + 启动 PurgeOrphanVaults)。
## 开发中被测试抓出的两个真实缺陷(我自己引入的)
写了 TestNoKernelActionDeadlocksFromLua 对**每个**动作断言「在插件 Lua 调用
(此时持有 p.mu)内不得死锁」,抓出:
1. **自死锁**:set_plugin_enabled → SetEnabled → p.mu.Lock(),而死锁发生在
调用者已持有的同一把锁上。
2. **更隐蔽的迟到副作用**:超时把 error 返回给插件后,动作仍在幕后执行完成——
插件以为失败,实际已生效。
修法:标注 Deferred 的动作在本次调用返回后异步执行,如实返回 accepted:true
而不是谎报成功。18 个动作全部复验无死锁,另有单独测试确认副作用真的落地
(只测「不死锁」的话,一个全空转的 dispatch 也能通过)。
测试 11 个(core 8 + lua 3):动作可用/密钥掩码/默认拒绝/授权计数/
插件注册源并变为可路由/授权落盘重启保留/未知动作名被拒/凭据面排除明文/
全动作无死锁/破坏性动作生效/Lua 侧桥接可用。
This commit is contained in:
@ -69,6 +69,11 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
// to boot the gateway because a third-party .lua has a typo would be the
|
||||
// wrong trade.
|
||||
c.plugins = lua.NewPlugins(c.vm, cfg.PluginDir)
|
||||
// R4-c: hand plugins the kernel action surface. Injected as an interface
|
||||
// because lua must not import core (core depends on lua, not the reverse).
|
||||
// Wired BEFORE LoadDir so a plugin that calls an action during its own
|
||||
// __init/first hook already finds a working kernel rather than a nil one.
|
||||
c.plugins.SetKernelAPI(c)
|
||||
// Plugin outbound HTTP is scoped to the hosts of configured sources. Set
|
||||
// before LoadDir so a plugin that requests on its first hook already sees
|
||||
// the real allowlist rather than the deny-all placeholder.
|
||||
|
||||
396
internal/core/kernel_actions.go
Normal file
396
internal/core/kernel_actions.go
Normal file
@ -0,0 +1,396 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 内核动作实现(R4-c)
|
||||
//
|
||||
// 把 Core 的公开动作暴露给插件 Lua,走 lua.KernelAPI 接口注入(依赖方向是
|
||||
// core → lua,所以接口定义在 lua 包、实现放在这里)。
|
||||
//
|
||||
// # 为什么是"动作面"而不是"给插件一个 Core 指针"
|
||||
//
|
||||
// 直接暴露 *Core 会让插件能碰任何未导出的细节,而且每一次内核重构都会变成
|
||||
// 对插件作者的破坏性变更。动作面是一份**显式的契约**:每个动作有名字、危险级、
|
||||
// 超时预算,加动作是一次有意的行为,而不是顺手多导出一个方法。
|
||||
//
|
||||
// # 凭据边界
|
||||
//
|
||||
// 这里**不实现** list_keys / find_key。Core.ListKeys() 返回的是**明文**网关
|
||||
// 密钥,把它交给第三方 Lua 等于把部署的整个凭据面交出去。key 相关的读走掩码
|
||||
// 形态(create_key 的返回是唯一一次明文,且是"刚创建的那一个")。
|
||||
var _ lua.KernelAPI = (*Core)(nil)
|
||||
|
||||
// Actions implements lua.KernelAPI.
|
||||
//
|
||||
// The catalog lives in the lua package because permissions must be checkable even
|
||||
// when no Core is wired. This method exists so the interface is satisfied by the
|
||||
// real kernel rather than by a separate adapter type.
|
||||
func (c *Core) Actions() []lua.KernelAction { return lua.KernelActions() }
|
||||
|
||||
// Invoke implements lua.KernelAPI. It dispatches one action.
|
||||
//
|
||||
// Every action is expected to honour ctx. The caller (lua.callKernel) gives up on
|
||||
// a deadline and returns an error to the plugin, but a kernel action that keeps
|
||||
// running afterwards would be an invisible leak, so the long ones (probe_all)
|
||||
// pass ctx down into the network layer.
|
||||
func (c *Core) Invoke(ctx context.Context, name string, args map[string]interface{}) (interface{}, error) {
|
||||
switch name {
|
||||
// ---- 只读 ----
|
||||
case "status":
|
||||
return c.actionStatus(), nil
|
||||
case "list_sources":
|
||||
return c.actionListSources(), nil
|
||||
case "list_models":
|
||||
return c.actionListModels(), nil
|
||||
case "list_adapters":
|
||||
return c.actionListAdapters(), nil
|
||||
case "list_auto_rules":
|
||||
return c.actionListAutoRules(), nil
|
||||
case "adapter_owner":
|
||||
return c.actionAdapterOwner(args)
|
||||
|
||||
// ---- 写 ----
|
||||
case "reload":
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true}, c.Reload()
|
||||
case "reset_health":
|
||||
// ResetHealth already no-ops on a nil registry (it ranges over
|
||||
// Providers()), so this needs no guard beyond the nil check inside.
|
||||
c.ResetHealth()
|
||||
return map[string]interface{}{"ok": true}, nil
|
||||
case "probe_all":
|
||||
r := c.Registry()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("the registry is not built yet")
|
||||
}
|
||||
r.ProbeAll(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("probe_all was cut short: %w", err)
|
||||
}
|
||||
return map[string]interface{}{"ok": true}, nil
|
||||
case "save_auto_rules":
|
||||
return c.actionSaveAutoRules(args)
|
||||
case "save_key_auto":
|
||||
return c.actionSaveKeyAuto(args)
|
||||
case "add_source":
|
||||
return c.actionAddSource(args)
|
||||
case "create_key":
|
||||
return c.actionCreateKey(args)
|
||||
|
||||
// ---- 破坏性 ----
|
||||
case "remove_source":
|
||||
return c.actionRemoveSource(args)
|
||||
case "delete_key":
|
||||
return c.actionDeleteKey(args)
|
||||
case "update_key":
|
||||
return c.actionUpdateKey(args)
|
||||
case "remove_adapter":
|
||||
return c.actionRemoveAdapter(args)
|
||||
case "set_plugin_enabled":
|
||||
return c.actionSetPluginEnabled(args)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown kernel action %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 只读实现 ----
|
||||
|
||||
func (c *Core) actionStatus() interface{} {
|
||||
// A plugin can call this DURING LoadDir (its own __init or first hook), which
|
||||
// runs before rebuildRegistry builds the registry. Returning an empty status
|
||||
// rather than panicking keeps a load-time call from taking the process down;
|
||||
// the plugin sees an empty list and the next call gives real data.
|
||||
out := map[string]interface{}{
|
||||
"sources": []interface{}{},
|
||||
"auto_slots": []interface{}{},
|
||||
"default": c.cfg.DefaultModel,
|
||||
"ready": false,
|
||||
}
|
||||
if r := c.Registry(); r != nil {
|
||||
out["sources"] = r.Status()
|
||||
out["ready"] = true
|
||||
}
|
||||
if slots := c.AutoSlotStates(); slots != nil {
|
||||
out["auto_slots"] = slots
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// actionListSources returns the sources with api_key MASKED.
|
||||
//
|
||||
// Masking is not optional: a plugin has no legitimate need for another source's
|
||||
// key, and a plugin that wrote the source itself already has it in its own
|
||||
// credential vault.
|
||||
func (c *Core) actionListSources() interface{} {
|
||||
srcs := c.Sources()
|
||||
out := make([]map[string]interface{}, 0, len(srcs))
|
||||
for _, s := range srcs {
|
||||
models := make([]string, 0, len(s.Models))
|
||||
for _, m := range s.Models {
|
||||
models = append(models, m.ID)
|
||||
}
|
||||
out = append(out, map[string]interface{}{
|
||||
"name": s.Name,
|
||||
"base_url": s.BaseURL,
|
||||
"adapter": s.Adapter,
|
||||
"models": models,
|
||||
"has_key": s.APIKey != "" || s.APIKeyEnv != "",
|
||||
"key_hint": maskSecretHint(s.APIKey),
|
||||
"max_concurrent": s.MaxConcurrent,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// maskSecretHint shows only a short suffix so an operator (and a plugin) can tell
|
||||
// two keys apart without either getting the secret.
|
||||
func maskSecretHint(k string) string {
|
||||
if k == "" {
|
||||
return ""
|
||||
}
|
||||
if len(k) <= 4 {
|
||||
return "***"
|
||||
}
|
||||
return "***" + k[len(k)-4:]
|
||||
}
|
||||
|
||||
func (c *Core) actionListModels() interface{} {
|
||||
if r := c.Registry(); r != nil {
|
||||
return r.ModelList()
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (c *Core) actionListAdapters() interface{} {
|
||||
return c.ListAdapters()
|
||||
}
|
||||
|
||||
func (c *Core) actionListAutoRules() interface{} {
|
||||
return map[string]interface{}{
|
||||
"auto": c.AutoRules(),
|
||||
"auto_image": c.AutoImageRules(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Core) actionAdapterOwner(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("adapter_owner requires a name")
|
||||
}
|
||||
owner := ""
|
||||
if ps := c.Plugins(); ps != nil {
|
||||
owner = ps.AdapterOwnerOf(name)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"name": name,
|
||||
"owner": owner, // "" means operator-installed
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- 写实现 ----
|
||||
|
||||
func (c *Core) actionSaveAutoRules(args map[string]interface{}) (interface{}, error) {
|
||||
entries, err := decodeScopes(args["entries"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.SaveAutoRules(entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "count": len(entries)}, nil
|
||||
}
|
||||
|
||||
func (c *Core) actionSaveKeyAuto(args map[string]interface{}) (interface{}, error) {
|
||||
keyName, _ := args["key_name"].(string)
|
||||
if keyName == "" {
|
||||
return nil, fmt.Errorf("save_key_auto requires key_name")
|
||||
}
|
||||
entries, err := decodeScopes(args["entries"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.SaveKeyAuto(keyName, entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "count": len(entries)}, nil
|
||||
}
|
||||
|
||||
// actionAddSource is the capability that makes "plugin-driven upstream" real: a
|
||||
// plugin that has just obtained credentials can wire its endpoint in as a source
|
||||
// without the operator editing config.yaml.
|
||||
func (c *Core) actionAddSource(args map[string]interface{}) (interface{}, error) {
|
||||
raw, ok := args["source"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("add_source requires a source table")
|
||||
}
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("source is not encodable: %w", err)
|
||||
}
|
||||
var src config.Source
|
||||
if err := json.Unmarshal(b, &src); err != nil {
|
||||
return nil, fmt.Errorf("source shape is invalid: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(src.Name) == "" {
|
||||
return nil, fmt.Errorf("source requires a name")
|
||||
}
|
||||
if strings.TrimSpace(src.BaseURL) == "" {
|
||||
return nil, fmt.Errorf("source %s requires a base_url", src.Name)
|
||||
}
|
||||
if err := c.AddSource(src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": src.Name}, nil
|
||||
}
|
||||
|
||||
// actionCreateKey creates a gateway key.
|
||||
//
|
||||
// The plaintext key is returned ONCE, in this response — the same posture as the
|
||||
// admin API. It is not retrievable later, so a plugin that needs it must store it
|
||||
// in its own credential vault immediately.
|
||||
func (c *Core) actionCreateKey(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
role, _ := args["role"].(string)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
models, err := decodeScopes(args["models"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, err := c.CreateKey(name, role, models, "created by plugin")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"key": k.Key,
|
||||
"name": k.Name,
|
||||
"role": k.Role,
|
||||
"models": k.Models,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- 破坏性实现 ----
|
||||
|
||||
func (c *Core) actionRemoveSource(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("remove_source requires a name")
|
||||
}
|
||||
// The last source is not removable: an empty registry turns every request
|
||||
// into a 503, and the operator would have to fix it from the filesystem.
|
||||
if len(c.Sources()) <= 1 {
|
||||
return nil, fmt.Errorf("refusing to remove the last source: the gateway would have nothing to route to")
|
||||
}
|
||||
if err := c.RemoveSource(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": name}, nil
|
||||
}
|
||||
|
||||
func (c *Core) actionDeleteKey(args map[string]interface{}) (interface{}, error) {
|
||||
key, _ := args["key"].(string)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("delete_key requires the key to delete")
|
||||
}
|
||||
ok, err := c.DeleteKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no such key")
|
||||
}
|
||||
return map[string]interface{}{"ok": true}, nil
|
||||
}
|
||||
|
||||
func (c *Core) actionUpdateKey(args map[string]interface{}) (interface{}, error) {
|
||||
key, _ := args["key"].(string)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("update_key requires the key")
|
||||
}
|
||||
name, _ := args["name"].(string)
|
||||
role, _ := args["role"].(string)
|
||||
models, err := decodeScopes(args["models"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, err := c.UpdateKey(key, name, role, models, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": k.Name, "role": k.Role}, nil
|
||||
}
|
||||
|
||||
// actionRemoveAdapter deletes an adapter, but only one the CALLER plugin owns —
|
||||
// the ownership check lives in Plugins.RemoveAdapter (see the adapter bridge).
|
||||
// A plugin can always remove its own; removing an operator's or a built-in is
|
||||
// refused there, and that refusal is the security property.
|
||||
func (c *Core) actionRemoveAdapter(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("remove_adapter requires a name")
|
||||
}
|
||||
ps := c.Plugins()
|
||||
if ps == nil {
|
||||
return nil, fmt.Errorf("no plugins loaded")
|
||||
}
|
||||
plugin, _ := args["plugin"].(string)
|
||||
if plugin == "" {
|
||||
return nil, fmt.Errorf("remove_adapter needs the calling plugin's name")
|
||||
}
|
||||
if err := ps.RemoveAdapter(plugin, name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": name}, nil
|
||||
}
|
||||
|
||||
func (c *Core) actionSetPluginEnabled(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("set_plugin_enabled requires a name")
|
||||
}
|
||||
enabled, ok := args["enabled"].(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("set_plugin_enabled requires enabled (boolean)")
|
||||
}
|
||||
ps := c.Plugins()
|
||||
if ps == nil {
|
||||
return nil, fmt.Errorf("no plugins loaded")
|
||||
}
|
||||
if err := ps.SetEnabled(name, enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": name, "enabled": enabled}, nil
|
||||
}
|
||||
|
||||
// decodeScopes converts a Lua-decoded array of model-scope tables into
|
||||
// []config.ModelScope. It accepts both an explicit array and a single table so a
|
||||
// one-entry chain does not need a wrapper.
|
||||
func decodeScopes(v interface{}) ([]config.ModelScope, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entries are not encodable: %w", err)
|
||||
}
|
||||
var list []config.ModelScope
|
||||
if err := json.Unmarshal(b, &list); err == nil {
|
||||
return list, nil
|
||||
}
|
||||
var one config.ModelScope
|
||||
if err := json.Unmarshal(b, &one); err != nil {
|
||||
return nil, fmt.Errorf("entries shape is invalid: %w", err)
|
||||
}
|
||||
return []config.ModelScope{one}, nil
|
||||
}
|
||||
336
internal/core/kernel_actions_test.go
Normal file
336
internal/core/kernel_actions_test.go
Normal file
@ -0,0 +1,336 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 本文件验证 R4-c/R4-d 的闭环:**插件自己驱动外部源的链接认证,然后把源接进内核**。
|
||||
//
|
||||
// 这是用户点明的"同类插件"能力(对照 dsh-our-free-model):插件主导 OAuth 登录、
|
||||
// 存凭据、定时领额度、把拿到的端点注册成内核上游。此前 llmsproxy 的插件只能管
|
||||
// 自己的适配器,够不到内核。
|
||||
|
||||
// kernelTestCore 起一个最小 core(一个源 + 一个 key)。
|
||||
func kernelTestCore(t *testing.T) *Core {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
PluginDir: filepath.Join(dir, "plugins"),
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-kernel-0001"},
|
||||
Sources: []config.Source{{
|
||||
Name: "primary", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
|
||||
APIKey: "secret-primary-key", MaxConcurrent: 1,
|
||||
Models: []config.Model{{ID: "m1", Kind: "chat"}},
|
||||
}},
|
||||
}
|
||||
c, err := NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
return c
|
||||
}
|
||||
|
||||
// grant 给插件授权若干动作(模拟运维在设置页点选)。
|
||||
func grant(t *testing.T, c *Core, plugin string, actions ...string) {
|
||||
t.Helper()
|
||||
if err := c.Plugins().SetPluginKernelPermission(plugin, actions, false); err != nil {
|
||||
t.Fatalf("grant %v to %s: %v", actions, plugin, err)
|
||||
}
|
||||
}
|
||||
|
||||
const kernelProbePlugin = `
|
||||
local plugin = { name = "kernelprobe", version = "1.0" }
|
||||
plugin.exports = { "read_status", "read_models", "read_sources", "try_delete_key", "probe_count", "call_reload" }
|
||||
plugin.kernel = { "status", "list_models", "reload" }
|
||||
|
||||
function plugin.read_status()
|
||||
local st, err = __llmsproxy_kernel.call("status", {})
|
||||
if err then return { err = err } end
|
||||
return { ready = st.ready, nsources = #(st.sources or {}) }
|
||||
end
|
||||
|
||||
function plugin.read_models()
|
||||
local m, err = __llmsproxy_kernel.call("list_models", {})
|
||||
if err then return { err = err } end
|
||||
return { models = m }
|
||||
end
|
||||
|
||||
-- sources 动作返回的密钥必须是掩码的
|
||||
function plugin.read_sources()
|
||||
local s, err = __llmsproxy_kernel.call("list_sources", {})
|
||||
if err then return { err = err } end
|
||||
return { sources = s }
|
||||
end
|
||||
|
||||
-- 未授权的破坏性动作必须被拒
|
||||
function plugin.try_delete_key()
|
||||
local _, err = __llmsproxy_kernel.call("delete_key", { key = "sk-kernel-0001" })
|
||||
return { err = err }
|
||||
end
|
||||
|
||||
-- 授权数量可见(插件据此优雅降级)
|
||||
function plugin.probe_count()
|
||||
local acts = __llmsproxy_kernel.actions()
|
||||
local granted = 0
|
||||
for _, a in ipairs(acts) do
|
||||
if a.granted then granted = granted + 1 end
|
||||
end
|
||||
return { total = #acts, granted = granted }
|
||||
end
|
||||
|
||||
function plugin.call_reload()
|
||||
local r, err = __llmsproxy_kernel.call("reload", {})
|
||||
return { ok = r and r.ok, err = err }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
|
||||
// TestPluginCallsKernelActions 验证插件能调内核动作,且权限边界成立。
|
||||
func TestPluginCallsKernelActions(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "kernelprobe", "status", "list_models", "list_sources")
|
||||
|
||||
// 只读动作可用
|
||||
out, err := c.Plugins().Call("kernelprobe", "read_status", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("read_status: %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["err"] != nil {
|
||||
t.Fatalf("status 调用失败: %v", m["err"])
|
||||
}
|
||||
if m["ready"] != true {
|
||||
t.Errorf("registry 应已就绪: %#v", m)
|
||||
}
|
||||
if n, _ := m["nsources"].(float64); n < 1 {
|
||||
t.Errorf("应至少有 1 个源, 实得 %v", m["nsources"])
|
||||
}
|
||||
|
||||
// 模型列表
|
||||
out, err = c.Plugins().Call("kernelprobe", "read_models", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if models, ok := out.(map[string]interface{})["models"].([]interface{}); !ok || len(models) == 0 {
|
||||
t.Errorf("模型列表应为非空数组: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelActionMasksSourceKeys 是安全断言:list_sources **不得**泄漏 api_key。
|
||||
func TestKernelActionMasksSourceKeys(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "kernelprobe", "list_sources")
|
||||
|
||||
out, err := c.Plugins().Call("kernelprobe", "read_sources", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 把整个返回值序列化后检查:明文密钥绝不能出现在里面
|
||||
b, _ := json.Marshal(out)
|
||||
if s := string(b); json.Valid(b) && contains(s, "secret-primary-key") {
|
||||
t.Fatalf("list_sources 泄漏了源的明文 api_key: %s", s)
|
||||
}
|
||||
// 但应有可区分性(has_key + 掩码后缀)
|
||||
if !contains(string(b), "***") {
|
||||
t.Errorf("应给出掩码提示而不是什么都没有: %s", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelActionDeniedWithoutGrant 是权限默认拒绝的断言。
|
||||
func TestKernelActionDeniedWithoutGrant(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 故意不授权任何动作
|
||||
out, err := c.Plugins().Call("kernelprobe", "try_delete_key", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("调用本身不该报错(拒绝应作为返回值): %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["err"] == nil || m["err"] == "" {
|
||||
t.Fatal("未授权的 delete_key 必须被拒")
|
||||
}
|
||||
t.Logf("拒绝信息: %v", m["err"])
|
||||
// 拒绝信息要点名动作与插件(否则运维无法行动)
|
||||
msg, _ := m["err"].(string)
|
||||
if !contains(msg, "delete_key") || !contains(msg, "kernelprobe") {
|
||||
t.Errorf("拒绝信息应点名动作与插件, 实得 %q", msg)
|
||||
}
|
||||
// 且密钥必须还在
|
||||
if _, ok := c.FindKey("sk-kernel-0001"); !ok {
|
||||
t.Fatal("被拒的动作不应真的执行")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelActionsReportGranted 插件能看到自己拿到了哪些权限。
|
||||
func TestKernelActionsReportGranted(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "kernelprobe", "status", "list_models")
|
||||
|
||||
out, err := c.Plugins().Call("kernelprobe", "probe_count", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
total, _ := m["total"].(float64)
|
||||
granted, _ := m["granted"].(float64)
|
||||
if total < 15 {
|
||||
t.Errorf("动作总数应 >=15, 实得 %v", total)
|
||||
}
|
||||
if granted != 2 {
|
||||
t.Errorf("授权数应为 2(status+list_models), 实得 %v", granted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginRegistersSourceIntoKernel 是"同类插件"的核心闭环:
|
||||
// 插件通过内核动作把一个新的外部源接进网关。
|
||||
func TestPluginRegistersSourceIntoKernel(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
code := `
|
||||
local plugin = { name = "upstream", version = "1.0" }
|
||||
plugin.exports = { "add_my_source" }
|
||||
plugin.kernel = { "add_source", "list_sources" }
|
||||
|
||||
function plugin.add_my_source(args)
|
||||
local src = {
|
||||
name = "plugin-provided",
|
||||
base_url = "http://127.0.0.1:9/v1",
|
||||
adapter = "openai",
|
||||
api_key = "plugin-managed-token",
|
||||
max_concurrent = 2,
|
||||
models = { { id = "plugin-model", kind = "chat", priority = 10 } },
|
||||
}
|
||||
local r, err = __llmsproxy_kernel.call("add_source", { source = src })
|
||||
if err then return { err = err } end
|
||||
return { ok = r.ok, name = r.name }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("upstream", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "upstream", "add_source", "list_sources")
|
||||
|
||||
out, err := c.Plugins().Call("upstream", "add_my_source", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("add_source: %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["err"] != nil {
|
||||
t.Fatalf("插件注册源失败: %v", m["err"])
|
||||
}
|
||||
if m["name"] != "plugin-provided" {
|
||||
t.Fatalf("返回的源名不对: %#v", m)
|
||||
}
|
||||
|
||||
// 内核侧必须真的有了这个源 —— 这是"接进网关"的定义
|
||||
found := false
|
||||
for _, s := range c.Sources() {
|
||||
if s.Name == "plugin-provided" {
|
||||
found = true
|
||||
if s.BaseURL != "http://127.0.0.1:9/v1" {
|
||||
t.Errorf("base_url 未落库: %q", s.BaseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("插件注册的源没有出现在内核源列表里")
|
||||
}
|
||||
// 且模型已可路由
|
||||
for _, id := range c.Registry().ModelList() {
|
||||
if id == "plugin-model" {
|
||||
return // 通过
|
||||
}
|
||||
}
|
||||
t.Errorf("插件注册的模型未进入可路由列表: %v", c.Registry().ModelList())
|
||||
}
|
||||
|
||||
// TestKernelPermissionPersists 授权必须落盘(否则重启即失效)。
|
||||
func TestKernelPermissionPersists(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "kernelprobe", "status", "reload")
|
||||
|
||||
pdir := c.Config().PluginDir
|
||||
path := filepath.Join(pdir, ".kernel-perms.json")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("授权未落盘: %v", err)
|
||||
}
|
||||
if !contains(string(b), "reload") {
|
||||
t.Errorf("落盘内容缺少动作名: %s", b)
|
||||
}
|
||||
|
||||
// 模拟重启:新建 Plugins、加载授权、权限仍在
|
||||
vm2 := lua.NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm2.Stop()
|
||||
ps2 := lua.NewPlugins(vm2, pdir)
|
||||
if err := ps2.LoadKernelPermissions(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perm := ps2.PluginKernelPermission("kernelprobe")
|
||||
if len(perm.Actions) != 2 {
|
||||
t.Fatalf("重启后授权应保留 2 项, 实得 %#v", perm.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownKernelActionRejected 拼错的动作名必须在授权时就被拒,
|
||||
// 而不是静默授予"什么都不允许"。
|
||||
func TestUnknownKernelActionRejected(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Plugins().SetPluginKernelPermission("kernelprobe", []string{"no_such_action"}, false); err == nil {
|
||||
t.Fatal("未知动作名应被拒")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelActionSurfaceExcludesPlaintextKeys 是凭据边界的回归断言:
|
||||
// 动作面里绝不能出现返回明文密钥的动作(ListKeys/FindKey 被有意排除)。
|
||||
func TestKernelActionSurfaceExcludesPlaintextKeys(t *testing.T) {
|
||||
for _, a := range lua.KernelActions() {
|
||||
switch a.Name {
|
||||
case "list_keys", "find_key", "get_key":
|
||||
t.Fatalf("动作面出现了明文密钥读取动作 %q —— 这会把部署的凭据面交给插件", a.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool {
|
||||
return len(needle) > 0 && len(haystack) >= len(needle) &&
|
||||
func() bool {
|
||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||
if haystack[i:i+len(needle)] == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}()
|
||||
}
|
||||
39
internal/core/kernel_effect_test.go
Normal file
39
internal/core/kernel_effect_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestGrantedDestructiveActionTakesEffect 确认授权后的破坏性动作**真的执行**。
|
||||
//
|
||||
// 上一条测试只证明"不死锁、有返回"。一个把所有动作都变成空转的 dispatch 也能
|
||||
// 通过它 —— 所以这条单独断言副作用确实落地(密钥真的被删)。
|
||||
func TestGrantedDestructiveActionTakesEffect(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
code := `
|
||||
local plugin = { name = "eff", version = "1.0" }
|
||||
plugin.exports = { "del" }
|
||||
function plugin.del(args)
|
||||
local r, err = __llmsproxy_kernel.call("delete_key", { key = args.key })
|
||||
return { ok = r ~= nil, err = err }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("eff", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = c.Plugins().SetPluginKernelPermission("eff", []string{"delete_key"}, false)
|
||||
|
||||
if _, ok := c.FindKey("sk-kernel-0001"); !ok {
|
||||
t.Fatal("前置条件:key 应存在")
|
||||
}
|
||||
out, err := c.Plugins().Call("eff", "del", map[string]interface{}{"key": "sk-kernel-0001"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("动作未成功: %#v", m)
|
||||
}
|
||||
if _, ok := c.FindKey("sk-kernel-0001"); ok {
|
||||
t.Fatal("★ 动作报告成功但副作用没发生 —— dispatch 是空转")
|
||||
}
|
||||
}
|
||||
69
internal/core/kernel_no_deadlock_test.go
Normal file
69
internal/core/kernel_no_deadlock_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// TestNoKernelActionDeadlocksFromLua 对**每个**内核动作做同一条断言:
|
||||
// 在插件 Lua 调用(此时持有 p.mu)内执行时不得死锁。
|
||||
//
|
||||
// 为什么是"每个"而不是"那个出问题的":最初只有 set_plugin_enabled 被发现自死锁
|
||||
// (它经 SetEnabled 重入 p.mu),但"是否还有别的动作也会重入"只能靠枚举回答。
|
||||
// 这条测试就是那次枚举的固化 —— 将来新增动作时如果也重入插件锁,它会失败。
|
||||
//
|
||||
// 同时它验证了"超时后副作用仍在幕后执行"这个更隐蔽的变体:那个缺陷的表现是
|
||||
// 插件收到 error、但动作其实生效了。断言方式只是"不死锁 + 返回",因为死锁与
|
||||
// 迟到副作用是同一个根因(在错误的锁上下文里执行)。
|
||||
func TestNoKernelActionDeadlocksFromLua(t *testing.T) {
|
||||
for _, a := range lua.KernelActions() {
|
||||
a := a
|
||||
t.Run(a.Name, func(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
code := `
|
||||
local plugin = { name = "probe_all_actions", version = "1.0" }
|
||||
plugin.exports = { "run" }
|
||||
function plugin.run(args)
|
||||
local r, err = __llmsproxy_kernel.call(args.action, args.arg or {})
|
||||
return { ok = r ~= nil, err = err }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("probe_all_actions", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 授权所有动作(含破坏性),以便真正执行到
|
||||
all := make([]string, 0)
|
||||
for _, x := range lua.KernelActions() {
|
||||
all = append(all, x.Name)
|
||||
}
|
||||
if err := c.Plugins().SetPluginKernelPermission("probe_all_actions", all, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
arg := map[string]interface{}{}
|
||||
switch a.Name {
|
||||
case "name", "adapter_owner":
|
||||
arg["name"] = "primary"
|
||||
}
|
||||
if a.Name == "adapter_owner" {
|
||||
arg = map[string]interface{}{"name": "openai"}
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, err := c.Plugins().Call("probe_all_actions", "run",
|
||||
map[string]interface{}{"action": a.Name, "arg": arg})
|
||||
if err != nil {
|
||||
t.Logf("%s 返回 err=%v", a.Name, err)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(8 * time.Second):
|
||||
t.Fatalf("动作 %s 在 Lua 调用内死锁(deferred=%v danger=%s)", a.Name, a.Deferred, a.Danger)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user