mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47: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:
117
docs/design-plugin-kernel-api.md
Normal file
117
docs/design-plugin-kernel-api.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# 插件可调用内核动作(R4-c)设计
|
||||||
|
|
||||||
|
> 用户决策(2026-10-11):**全开**(13 个内核动作全部可调)+ **设置页可配权限** +
|
||||||
|
> **Go 侧 deadline 兜底**。
|
||||||
|
|
||||||
|
## 1. 要解决的问题
|
||||||
|
|
||||||
|
用户原话:"现在这个接口不全,可以多给一些接口让 lua 调用内核。"
|
||||||
|
|
||||||
|
现状(`__llmsproxy_adapters`)是我手写的**三个闭包**(install/update/remove),
|
||||||
|
只能管适配器。内核还有 13 个公开动作(Reload / ResetHealth / AddSource /
|
||||||
|
RemoveSource / SaveAutoRules / key 增删改 / 列表读取…),插件一个都调不到。
|
||||||
|
|
||||||
|
而"自动孵化 / 自愈 / 联动"这类插件能力(例如:探测到上游全挂 → 触发重探 +
|
||||||
|
清冷却;发现额度变化 → 重排 AUTO 链)恰恰需要它们。
|
||||||
|
|
||||||
|
## 2. 关键认识:Go 调用可以超时,Lua 死循环不能
|
||||||
|
|
||||||
|
这决定了整个设计,也是用户提"阻塞 + Go 兜底"的原因:
|
||||||
|
|
||||||
|
| 形态 | 能否中断 | 处置方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| Lua 里 `while true do end` | **不能**(LuaJIT 限制,已实测三次) | 只能停用插件 |
|
||||||
|
| Lua 里 `__llmsproxy_kernel.call("reload")` | **能** | Go 侧 `select` + deadline,超时把错误返回给 Lua |
|
||||||
|
|
||||||
|
因为 Lua 只是在等 Go 的返回值,**不占用 Lua 栈的执行**。所以给每次内核调用加
|
||||||
|
Go 侧 deadline 是可行的、干净的 —— 不需要"Lua 投递请求、Go 队列执行":那样
|
||||||
|
还要定义"超时后队列里积压的命令怎么办",而 deadline 直接返回 `(nil, err)`,
|
||||||
|
语义闭合。
|
||||||
|
|
||||||
|
## 3. 依赖方向
|
||||||
|
|
||||||
|
`internal/lua` **不能** import `internal/core`(方向是 core → lua)。
|
||||||
|
按既有先例(`SetOutboundHosts`)注入一个**接口**,由 core 实现:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/lua/kernel_api.go
|
||||||
|
type KernelAction struct {
|
||||||
|
Name string // "reload" / "reset_health" / ...
|
||||||
|
Title string // 设置页展示用
|
||||||
|
Description string
|
||||||
|
Danger string // ""=只读, "write"=改配置, "danger"=破坏性
|
||||||
|
Params []string // 参数名(设置页/文档用)
|
||||||
|
}
|
||||||
|
|
||||||
|
type KernelAPI interface {
|
||||||
|
// Actions 列出可调用动作(设置页据此渲染权限开关)
|
||||||
|
Actions() []KernelAction
|
||||||
|
// Invoke 执行一个动作,args 为已解码参数。**必须**尊重 ctx 的 deadline。
|
||||||
|
Invoke(ctx context.Context, name string, args map[string]any) (any, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
core 侧实现 `Invoke` 时用**现有的 Core 方法**,本身不持 `p.mu`,因此不会与
|
||||||
|
插件锁成环(已核实:所有 `ps.mu` → `p.mu` 都是先释放 `ps.mu`)。
|
||||||
|
|
||||||
|
## 4. 权限模型
|
||||||
|
|
||||||
|
复用 `visibilityOverrides` 的**同一套持久化机制**(`.access.json`),
|
||||||
|
但用独立的键空间:`kernel:<plugin>` → 逗号分隔的动作名或 `*`。
|
||||||
|
|
||||||
|
- **默认拒绝**:插件不显式获得授权时,`call` 返回 `(nil, "not permitted")`。
|
||||||
|
理由:全开是"能力存在",不是"默认授予"。一个插件能删密钥这件事必须由
|
||||||
|
运维在设置页点过确认。
|
||||||
|
- **`danger` 类必须显式逐个授权**,不接受 `*`。这一条是防止运维图省事写
|
||||||
|
通配符,把"删密钥"和"读状态"等同起来。
|
||||||
|
- 插件可以在自己源码里**声明**它想要的动作(`plugin.kernel = {...}`),
|
||||||
|
设置页把它显示为"申请",运维批准后才生效。声明不等于授权。
|
||||||
|
|
||||||
|
## 5. 动作清单(13 个 + 适配器 3 个)
|
||||||
|
|
||||||
|
| 动作 | 危险级 | 对应 Core 方法 | 超时 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `status` | 只读 | 读 AutoSlotStates/Registry | 2s |
|
||||||
|
| `list_sources` | 只读 | Sources() | 2s |
|
||||||
|
| `list_models` | 只读 | Registry().ModelList() | 2s |
|
||||||
|
| `list_adapters` | 只读 | ListAdapters() | 2s |
|
||||||
|
| `adapter_owner` | 只读 | AdapterOwnerOf | 2s |
|
||||||
|
| `reload` | write | Reload() | 30s |
|
||||||
|
| `reset_health` | write | ResetHealth() | 5s |
|
||||||
|
| `probe_all` | write | Registry().ProbeAll(ctx) | 60s |
|
||||||
|
| `save_auto_rules` | write | SaveAutoRules() | 10s |
|
||||||
|
| `save_key_auto` | write | SaveKeyAuto() | 10s |
|
||||||
|
| `add_source` | write | AddSource() | 30s |
|
||||||
|
| `remove_source` | danger | RemoveSource() | 30s |
|
||||||
|
| `create_key` | write | CreateKey() | 10s |
|
||||||
|
| `update_key` | write | UpdateKey() | 10s |
|
||||||
|
| `delete_key` | danger | DeleteKey() | 10s |
|
||||||
|
| `install_adapter` | write | UploadAdapter() | 10s |
|
||||||
|
| `remove_adapter` | danger | RemoveAdapter() | 30s |
|
||||||
|
| `set_plugin_enabled` | danger | SetEnabled() | 5s |
|
||||||
|
|
||||||
|
**绝不开放**:`ListKeys()` / `FindKey()` —— 返回**明文密钥**。给插件读密钥等于
|
||||||
|
把网关的凭据面交给第三方 Lua。要用 key 相关动作就只用带掩码的形态。
|
||||||
|
|
||||||
|
## 6. Lua 调用面
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- 探测:拿到可用动作与自己的授权情况
|
||||||
|
local acts = __llmsproxy_kernel.actions()
|
||||||
|
-- 调用:统一入口,返回 (result, err)
|
||||||
|
local ok, err = __llmsproxy_kernel.call("reset_health", {})
|
||||||
|
local st, err = __llmsproxy_kernel.call("status", {})
|
||||||
|
```
|
||||||
|
|
||||||
|
统一入口(而不是每个动作一个全局函数)的理由:动作集合将来会变,而
|
||||||
|
`__llmsproxy_kernel` 的签名不变 —— 否则每加一个动作就要动
|
||||||
|
`bootPluginState`,而那里正是历史上出过 cgo 崩溃的地方。
|
||||||
|
|
||||||
|
## 7. 实施顺序
|
||||||
|
|
||||||
|
1. `internal/lua/kernel_api.go`:接口 + 动作元数据 + 权限检查 + deadline 包装
|
||||||
|
2. `internal/lua/plugin_kernel_bridge.go`:Lua 绑定(`actions` / `call`)
|
||||||
|
3. `internal/core/kernel_actions.go`:实现 `KernelAPI`(core 侧)
|
||||||
|
4. 权限持久化:复用 `.access.json`,新键空间
|
||||||
|
5. 设置页:插件行加"内核动作"列 + 授权编辑
|
||||||
|
6. 测试:Go 侧全动作、Lua 侧调用、权限拒绝、deadline 生效、危险动作需显式授权
|
||||||
@ -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
|
// to boot the gateway because a third-party .lua has a typo would be the
|
||||||
// wrong trade.
|
// wrong trade.
|
||||||
c.plugins = lua.NewPlugins(c.vm, cfg.PluginDir)
|
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
|
// 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
|
// before LoadDir so a plugin that requests on its first hook already sees
|
||||||
// the real allowlist rather than the deny-all placeholder.
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
452
internal/lua/kernel_api.go
Normal file
452
internal/lua/kernel_api.go
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
package lua
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Package lua — 插件可调用的内核动作面(R4-c)
|
||||||
|
//
|
||||||
|
// 用户需求(原话):「现在这个接口不全,可以多给一些接口让 lua 调用内核。」
|
||||||
|
//
|
||||||
|
// 在此之前,插件只能通过 __llmsproxy_adapters 管适配器 —— 那是三个手写闭包。
|
||||||
|
// 内核另有十几个公开动作(重载配置、清冷却、重探上游、增删源/密钥、改 AUTO 链…),
|
||||||
|
// 插件一个都调不到。而"自愈 / 联动 / 自动孵化"这类插件能力恰恰需要它们:
|
||||||
|
//
|
||||||
|
// 探测到上游整片挂掉 → probe_all + reset_health
|
||||||
|
// 发现额度变化 → save_auto_rules 重排链路
|
||||||
|
// 拿到新端点 → add_source
|
||||||
|
//
|
||||||
|
// # 为什么 Go 侧 deadline 是可行的(而 Lua 死循环不可中断)
|
||||||
|
//
|
||||||
|
// 这是整个设计的支点。两者形态完全不同:
|
||||||
|
//
|
||||||
|
// Lua 里 while true do end → 无法中断(LuaJIT 限制,实测三次)
|
||||||
|
// Lua 里 __llmsproxy_kernel.call(...) → **可以**超时
|
||||||
|
//
|
||||||
|
// 因为后者 Lua 只是在等一个返回值,不占用 Lua 栈的执行权。所以每次调用在 Go
|
||||||
|
// 侧包一层 select + ctx deadline:超时就把 (nil, err) 还给 Lua。这比"Lua 投递
|
||||||
|
// 请求、Go 队列执行"干净 —— 队列还要定义"超时后积压的命令算什么",而 deadline
|
||||||
|
// 的语义是闭合的。
|
||||||
|
//
|
||||||
|
// # 权限:全开,但默认不授予
|
||||||
|
//
|
||||||
|
// 用户选定「全开」(13 个动作都可达)+ 「设置页可配权限」。全开描述的是**能力
|
||||||
|
// 存在**,不是**默认授予**:一个插件能删密钥这件事必须由运维在设置页点过确认。
|
||||||
|
// 因此默认拒绝,授权记录在 .access.json 里(复用现有覆盖机制)。
|
||||||
|
//
|
||||||
|
// danger 级动作必须**逐个**授权,不接受通配符 —— 否则运维图省事写个 "*",
|
||||||
|
// "删密钥"就和"读状态"同权了。
|
||||||
|
|
||||||
|
// KernelAPI 是内核暴露给插件的能力面。由 internal/core 实现并注入。
|
||||||
|
//
|
||||||
|
// 依赖方向:internal/lua 不能 import internal/core(方向是 core → lua),
|
||||||
|
// 所以这里定义接口、core 侧实现 —— 与 SetOutboundHosts 同一个注入先例。
|
||||||
|
type KernelAPI interface {
|
||||||
|
// Actions 列出可调用的动作。设置页据此渲染权限开关,插件据此发现能力。
|
||||||
|
Actions() []KernelAction
|
||||||
|
// Invoke 执行一个动作。实现**必须**尊重 ctx:ctx 取消后应尽快返回,
|
||||||
|
// 否则插件侧的超时虽然会返回错误,但该动作仍在后台跑(变成不可见的泄漏)。
|
||||||
|
Invoke(ctx context.Context, name string, args map[string]interface{}) (interface{}, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 危险级。它决定权限授予的严格程度,也决定设置页用什么颜色提示。
|
||||||
|
const (
|
||||||
|
// DangerRead 无副作用,只读。仍然需要授权,但可以一次授予全部。
|
||||||
|
DangerRead = "read"
|
||||||
|
// DangerWrite 修改运行状态或配置,但可恢复(重载、清冷却、改链路)。
|
||||||
|
DangerWrite = "write"
|
||||||
|
// DangerDestructive 删除类,或让服务中断。**必须逐个授权**。
|
||||||
|
DangerDestructive = "destructive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KernelAction 描述一个可调用动作。
|
||||||
|
type KernelAction struct {
|
||||||
|
// Deferred marks an action that MUST NOT run while the Lua caller holds its
|
||||||
|
// plugin lock. The Lua entry points (hook, exported call, scheduled task) all
|
||||||
|
// hold p.mu for the whole call, and an action that re-enters the plugin layer
|
||||||
|
// (set_plugin_enabled reaches SetEnabled → p.mu.Lock()) would self-deadlock —
|
||||||
|
// and with a deadline, would then execute invisibly AFTER the plugin was told
|
||||||
|
// it failed.
|
||||||
|
//
|
||||||
|
// Such an action is queued and run on its own goroutine once the Lua call has
|
||||||
|
// returned, so the caller gets "accepted" rather than a lock-up or a lie.
|
||||||
|
Deferred bool
|
||||||
|
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Danger string `json:"danger"`
|
||||||
|
Params []string `json:"params,omitempty"`
|
||||||
|
// TimeoutMS is the Go-side deadline for one invocation. Long actions (a
|
||||||
|
// full re-probe walks every source over the network) need a bigger budget
|
||||||
|
// than a state read; making it per-action rather than one global number is
|
||||||
|
// what keeps the deadline honest rather than arbitrary.
|
||||||
|
TimeoutMS int `json:"timeout_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// kernelCatalog is the full action set. It lives in the lua package (rather than
|
||||||
|
// being discovered from the implementation) so that permissions can be validated
|
||||||
|
// even when no Core is wired — tests, and a gateway with plugins but no kernel.
|
||||||
|
//
|
||||||
|
// Every action here maps to an existing Core method; adding one is a deliberate
|
||||||
|
// act rather than a reflex. ListKeys/FindKey are deliberately ABSENT: they return
|
||||||
|
// PLAINTEXT gateway keys, and handing those to third-party Lua would hand over
|
||||||
|
// the deployment's entire credential surface. Key-related reads go through
|
||||||
|
// masked forms instead.
|
||||||
|
var kernelCatalog = []KernelAction{
|
||||||
|
// ---- 只读 ----
|
||||||
|
{Name: "status", Title: "运行状态", Danger: DangerRead, TimeoutMS: 3000,
|
||||||
|
Description: "各源的调度状态(冷却、连续失败、最近错误)"},
|
||||||
|
{Name: "list_sources", Title: "源列表", Danger: DangerRead, TimeoutMS: 3000,
|
||||||
|
Description: "已配置的上游源(api_key 已掩码)"},
|
||||||
|
{Name: "list_models", Title: "模型列表", Danger: DangerRead, TimeoutMS: 3000,
|
||||||
|
Description: "当前可路由的模型 id 列表"},
|
||||||
|
{Name: "list_adapters", Title: "适配器列表", Danger: DangerRead, TimeoutMS: 3000,
|
||||||
|
Description: "已加载的适配器名与版本"},
|
||||||
|
{Name: "list_auto_rules", Title: "AUTO 链路", Danger: DangerRead, TimeoutMS: 3000,
|
||||||
|
Description: "当前全局 AUTO 链的槽位"},
|
||||||
|
{Name: "adapter_owner", Title: "适配器归属", Danger: DangerRead, TimeoutMS: 3000,
|
||||||
|
Params: []string{"name"},
|
||||||
|
Description: "某适配器由谁安装(operator 或 plugin:名字)"},
|
||||||
|
|
||||||
|
// ---- 写(可恢复) ----
|
||||||
|
{Name: "reload", Title: "重载配置", Danger: DangerWrite, TimeoutMS: 30000,
|
||||||
|
Description: "重新读取运行配置并重建源/链路"},
|
||||||
|
{Name: "reset_health", Title: "清除冷却", Danger: DangerWrite, TimeoutMS: 5000,
|
||||||
|
Description: "清掉所有源的退避状态(修好上游后立即恢复)"},
|
||||||
|
{Name: "probe_all", Title: "全量探测", Danger: DangerWrite, TimeoutMS: 60000,
|
||||||
|
Description: "对每个源做一次真实可达性探测(走网络,慢)"},
|
||||||
|
{Name: "save_auto_rules", Title: "设置 AUTO 链", Danger: DangerWrite, TimeoutMS: 10000,
|
||||||
|
Params: []string{"entries"},
|
||||||
|
Description: "替换全局 AUTO 链;entries 为 [{model, source, ...}] 数组"},
|
||||||
|
{Name: "save_key_auto", Title: "设置按 key 链路", Danger: DangerWrite, TimeoutMS: 10000,
|
||||||
|
Params: []string{"key_name", "entries"},
|
||||||
|
Description: "替换某个网关 key 自己的 AUTO 链"},
|
||||||
|
{Name: "add_source", Title: "新增源", Danger: DangerWrite, TimeoutMS: 30000,
|
||||||
|
Params: []string{"source"},
|
||||||
|
Description: "把一个新的上游源接入(source 为源定义表)"},
|
||||||
|
{Name: "create_key", Title: "新建网关密钥", Danger: DangerWrite, TimeoutMS: 10000,
|
||||||
|
Params: []string{"name", "role", "models"},
|
||||||
|
Description: "创建一个网关 key(返回的明文密钥只在此次响应里出现)"},
|
||||||
|
|
||||||
|
// ---- 破坏性:必须逐个授权 ----
|
||||||
|
{Name: "remove_source", Title: "删除源", Danger: DangerDestructive, TimeoutMS: 30000,
|
||||||
|
Params: []string{"name"},
|
||||||
|
Description: "删除一个上游源"},
|
||||||
|
{Name: "delete_key", Title: "删除网关密钥", Danger: DangerDestructive, TimeoutMS: 10000,
|
||||||
|
Params: []string{"key"},
|
||||||
|
Description: "删除一个网关 key(正在使用的客户端会立刻 401)"},
|
||||||
|
{Name: "update_key", Title: "修改网关密钥", Danger: DangerDestructive, TimeoutMS: 10000,
|
||||||
|
Params: []string{"key", "name", "role", "models"},
|
||||||
|
Description: "改动 key 的角色/可见模型(可能扩大访问面)"},
|
||||||
|
{Name: "remove_adapter", Title: "删除适配器", Danger: DangerDestructive, TimeoutMS: 30000,
|
||||||
|
Params: []string{"name"},
|
||||||
|
Description: "删除适配器(仅限插件自己装的;内置与非自有的会被拒)"},
|
||||||
|
{Name: "set_plugin_enabled", Title: "启停插件", Danger: DangerDestructive, TimeoutMS: 5000,
|
||||||
|
Params: []string{"name", "enabled"}, Deferred: true,
|
||||||
|
Description: "启用或停用某个插件(异步执行:在本次调用返回后生效)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// kernelActionByName indexes the catalog.
|
||||||
|
var kernelActionByName = func() map[string]KernelAction {
|
||||||
|
m := make(map[string]KernelAction, len(kernelCatalog))
|
||||||
|
for _, a := range kernelCatalog {
|
||||||
|
m[a.Name] = a
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
// KernelActions returns the catalog sorted by name (stable for the UI).
|
||||||
|
func KernelActions() []KernelAction {
|
||||||
|
out := append([]KernelAction(nil), kernelCatalog...)
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupKernelAction finds an action, reporting whether it exists.
|
||||||
|
func LookupKernelAction(name string) (KernelAction, bool) {
|
||||||
|
a, ok := kernelActionByName[name]
|
||||||
|
return a, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 权限 ----
|
||||||
|
|
||||||
|
// errNotPermitted is returned when a plugin calls an action it was not granted.
|
||||||
|
// It names the action and the plugin so the operator can act on it directly.
|
||||||
|
func notPermitted(plugin, action string) error {
|
||||||
|
return fmt.Errorf("kernel action %q is not granted to plugin %q; "+
|
||||||
|
"enable it in the plugin's kernel permissions", action, plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KernelPermission is the operator's grant for one plugin: the set of action
|
||||||
|
// names it may call, plus whether it was granted "everything readable".
|
||||||
|
type KernelPermission struct {
|
||||||
|
// Actions is the explicit allowlist, already expanded (no wildcards stored,
|
||||||
|
// so a NEW action added in a future release is NOT silently granted to an
|
||||||
|
// existing plugin — that is the safe direction for a security default).
|
||||||
|
Actions []string
|
||||||
|
// AllRead is true when the operator granted every read-only action at once.
|
||||||
|
// Stored separately from Actions so that a future read-only action IS covered
|
||||||
|
// without re-saving, while writes still require an explicit grant.
|
||||||
|
AllRead bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// clone returns a copy so callers cannot mutate the stored permission.
|
||||||
|
func (p KernelPermission) clone() KernelPermission {
|
||||||
|
return KernelPermission{Actions: append([]string(nil), p.Actions...), AllRead: p.AllRead}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allows reports whether the permission covers action.
|
||||||
|
//
|
||||||
|
// The "all readable" grant deliberately does NOT cover a future WRITE action:
|
||||||
|
// expanding a wildcard into whatever happens to exist later is how a convenience
|
||||||
|
// grant turns into a privilege escalation.
|
||||||
|
func (p KernelPermission) allows(a KernelAction) bool {
|
||||||
|
if p.AllRead && a.Danger == DangerRead {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, n := range p.Actions {
|
||||||
|
if n == a.Name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 调用(带 Go 侧超时) ----
|
||||||
|
|
||||||
|
// ErrKernelUnavailable is returned when no KernelAPI was injected (a gateway
|
||||||
|
// running plugins without a wired core, e.g. some tests).
|
||||||
|
var ErrKernelUnavailable = errors.New("kernel actions are not available in this build")
|
||||||
|
|
||||||
|
// callKernel invokes one action with the permission check and the Go-side
|
||||||
|
// deadline, returning a value Lua can consume.
|
||||||
|
//
|
||||||
|
// The deadline is the whole point: a plugin can block here harmlessly, because
|
||||||
|
// Lua is waiting on a return value rather than spinning. On timeout the caller
|
||||||
|
// gets a normal error; the action's own goroutine is told to stop via ctx.
|
||||||
|
func (ps *Plugins) callKernel(plugin, name string, args map[string]interface{}) (interface{}, error) {
|
||||||
|
api := ps.kernel()
|
||||||
|
if api == nil {
|
||||||
|
return nil, ErrKernelUnavailable
|
||||||
|
}
|
||||||
|
action, ok := LookupKernelAction(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown kernel action %q", name)
|
||||||
|
}
|
||||||
|
if !ps.kernelPermitted(plugin, action) {
|
||||||
|
return nil, notPermitted(plugin, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(action.TimeoutMS) * time.Millisecond
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
// The caller's identity is injected HERE, not taken from args: an action that
|
||||||
|
// needs to know who is calling (remove_adapter, to enforce ownership) must not
|
||||||
|
// be able to be lied to by the plugin passing someone else's name.
|
||||||
|
if args == nil {
|
||||||
|
args = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
args["plugin"] = plugin
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
v interface{}
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
// A deferred action must not run under the caller's plugin lock, so it is
|
||||||
|
// queued and returns "accepted" immediately. Reporting success here without
|
||||||
|
// waiting is honest: the caller is told the action was ACCEPTED, and the
|
||||||
|
// actual outcome lands in the plugin's hook_errors surface if it fails.
|
||||||
|
if action.Deferred {
|
||||||
|
ps.runDeferred(action, args)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"accepted": true,
|
||||||
|
"note": "this action runs asynchronously; check the plugin state/errors for the outcome",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The action runs on its own goroutine so this call can give up on time.
|
||||||
|
// Buffered so a late-finishing action never blocks on sending.
|
||||||
|
done := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
// A panicking kernel action must not take the gateway down through a
|
||||||
|
// Lua call. Convert it into an error the plugin sees.
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
done <- result{err: fmt.Errorf("kernel action %s panicked: %v", name, rec)}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
v, err := api.Invoke(ctx, name, args)
|
||||||
|
done <- result{v: v, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case r := <-done:
|
||||||
|
return r.v, r.err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, fmt.Errorf("kernel action %q exceeded its %s budget", name, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDeferred executes a lock-re-entrant action outside the Lua call.
|
||||||
|
//
|
||||||
|
// It runs on a fresh goroutine precisely so it can take the plugin lock that the
|
||||||
|
// caller still holds; waiting for the caller to unwind first would need a
|
||||||
|
// handshake that is more machinery than the one action needs. A failure is
|
||||||
|
// surfaced through hook_errors, which is where an operator already looks for
|
||||||
|
// "a plugin's background work stopped working".
|
||||||
|
func (ps *Plugins) runDeferred(a KernelAction, args map[string]interface{}) {
|
||||||
|
api := ps.kernel()
|
||||||
|
if api == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timeout := time.Duration(a.TimeoutMS) * time.Millisecond
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
// Copy the args: the caller's map is still owned by the Lua call frame.
|
||||||
|
cp := make(map[string]interface{}, len(args))
|
||||||
|
for k, v := range args {
|
||||||
|
cp[k] = v
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
ps.hookErr.note(StageRequestEnd, fmt.Sprintf("deferred kernel action %s panicked: %v", a.Name, rec))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := api.Invoke(ctx, a.Name, cp); err != nil {
|
||||||
|
ps.hookErr.note(StageRequestEnd, fmt.Sprintf("deferred kernel action %s failed: %v", a.Name, err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// kernelPermitted applies the stored grant, with one deliberate exception: a
|
||||||
|
// plugin's OWN adapter may always be removed by that plugin (it is not a kernel
|
||||||
|
// resource, it is one the plugin created). Everything else needs a grant.
|
||||||
|
func (ps *Plugins) kernelPermitted(plugin string, a KernelAction) bool {
|
||||||
|
if a.Name == "remove_adapter" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ps.mu.RLock()
|
||||||
|
perm := ps.kernelPerms[plugin]
|
||||||
|
ps.mu.RUnlock()
|
||||||
|
return perm.allows(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPluginKernelPermission replaces one plugin's grant and persists it.
|
||||||
|
//
|
||||||
|
// Validation happens here rather than at call time so a typo'd action name is
|
||||||
|
// reported to the operator immediately instead of silently granting nothing.
|
||||||
|
func (ps *Plugins) SetPluginKernelPermission(plugin string, actions []string, allRead bool) error {
|
||||||
|
if ps.find(plugin) == nil {
|
||||||
|
return fmt.Errorf("plugin %s not found", plugin)
|
||||||
|
}
|
||||||
|
clean := make([]string, 0, len(actions))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, raw := range actions {
|
||||||
|
name := strings.TrimSpace(raw)
|
||||||
|
if name == "" || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a, ok := LookupKernelAction(name)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown kernel action %q", name)
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
clean = append(clean, a.Name)
|
||||||
|
}
|
||||||
|
sort.Strings(clean)
|
||||||
|
ps.mu.Lock()
|
||||||
|
if ps.kernelPerms == nil {
|
||||||
|
ps.kernelPerms = kernelPermissionSet{}
|
||||||
|
}
|
||||||
|
if len(clean) == 0 && !allRead {
|
||||||
|
delete(ps.kernelPerms, plugin)
|
||||||
|
} else {
|
||||||
|
ps.kernelPerms[plugin] = KernelPermission{Actions: clean, AllRead: allRead}
|
||||||
|
}
|
||||||
|
ps.saveKernelPermissionsLocked()
|
||||||
|
ps.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginKernelPermission returns a copy of one plugin's grant.
|
||||||
|
func (ps *Plugins) PluginKernelPermission(plugin string) KernelPermission {
|
||||||
|
ps.mu.RLock()
|
||||||
|
defer ps.mu.RUnlock()
|
||||||
|
return ps.kernelPerms[plugin].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// KernelPermissionView is the UI-facing form: what the plugin ASKED for (from its
|
||||||
|
// own source) next to what the operator GRANTED. The distinction matters — a
|
||||||
|
// plugin declaring `plugin.kernel = {...}` is a request, not a privilege.
|
||||||
|
type KernelPermissionView struct {
|
||||||
|
Plugin string `json:"plugin"`
|
||||||
|
Declared []string `json:"declared,omitempty"`
|
||||||
|
Granted []string `json:"granted,omitempty"`
|
||||||
|
AllRead bool `json:"all_read"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KernelPermissions lists every plugin's declared-vs-granted state for the UI.
|
||||||
|
func (ps *Plugins) KernelPermissions() []KernelPermissionView {
|
||||||
|
ps.mu.RLock()
|
||||||
|
defer ps.mu.RUnlock()
|
||||||
|
out := make([]KernelPermissionView, 0, len(ps.plugins))
|
||||||
|
for _, p := range ps.plugins {
|
||||||
|
perm := ps.kernelPerms[p.Info.Name]
|
||||||
|
out = append(out, KernelPermissionView{
|
||||||
|
Plugin: p.Info.Name,
|
||||||
|
Declared: append([]string(nil), p.KernelDeclared...),
|
||||||
|
Granted: append([]string(nil), perm.Actions...),
|
||||||
|
AllRead: perm.AllRead,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Plugin < out[j].Plugin })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// kernel returns the injected API, or nil.
|
||||||
|
func (ps *Plugins) kernel() KernelAPI {
|
||||||
|
ps.mu.RLock()
|
||||||
|
defer ps.mu.RUnlock()
|
||||||
|
return ps.kernelAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKernelAPI injects the kernel action implementation. Called during wiring,
|
||||||
|
// mirroring SetOutboundHosts.
|
||||||
|
func (ps *Plugins) SetKernelAPI(api KernelAPI) {
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
ps.kernelAPI = api
|
||||||
|
}
|
||||||
|
|
||||||
|
// kernelPermissionSet is the persisted per-plugin grant map.
|
||||||
|
type kernelPermissionSet map[string]KernelPermission
|
||||||
|
|
||||||
|
// jsonPermission is the on-disk form of one grant. Kept separate from
|
||||||
|
// KernelPermission so the persisted shape cannot drift with a struct refactor
|
||||||
|
// (the access file is read by older and newer binaries across an upgrade).
|
||||||
|
type jsonPermission struct {
|
||||||
|
Actions []string `json:"actions,omitempty"`
|
||||||
|
AllRead bool `json:"all_read,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p KernelPermission) marshal() jsonPermission {
|
||||||
|
return jsonPermission{Actions: p.Actions, AllRead: p.AllRead}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j jsonPermission) unmarshal() KernelPermission {
|
||||||
|
return KernelPermission{Actions: j.Actions, AllRead: j.AllRead}
|
||||||
|
}
|
||||||
91
internal/lua/kernel_perms_persist.go
Normal file
91
internal/lua/kernel_perms_persist.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package lua
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kernel action grants are OPERATOR policy, persisted separately from both
|
||||||
|
// plugin state and the endpoint/visibility overrides.
|
||||||
|
//
|
||||||
|
// Why a separate file rather than reusing .access.json:
|
||||||
|
//
|
||||||
|
// - .access.json is a flat map[string]string ("name" or "name#path" -> spec).
|
||||||
|
// Kernel grants are a list plus a boolean, so folding them in would either
|
||||||
|
// change the shape of a file an older binary still reads, or force an
|
||||||
|
// encoding that cannot round-trip. An upgrade must not corrupt a running
|
||||||
|
// deployment's restrictions.
|
||||||
|
// - The two answer different questions — "who may call this endpoint" vs
|
||||||
|
// "what may this plugin do to the kernel" — and an operator looking at one
|
||||||
|
// should not have to parse the other.
|
||||||
|
//
|
||||||
|
// Shape: { "<plugin>": { "actions": ["reload", ...], "all_read": true } }
|
||||||
|
|
||||||
|
const kernelPermsFileName = ".kernel-perms.json"
|
||||||
|
|
||||||
|
func (ps *Plugins) kernelPermsFile() string {
|
||||||
|
if ps.dir == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ps.dir + string(os.PathSeparator) + kernelPermsFileName
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadKernelPermissions reads the persisted grants. A missing file is the normal
|
||||||
|
// fresh-install state (nothing granted); a corrupt file is logged and ignored
|
||||||
|
// rather than fatal — losing a grant is recoverable, refusing to boot is not.
|
||||||
|
func (ps *Plugins) LoadKernelPermissions() error {
|
||||||
|
path := ps.kernelPermsFile()
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw := map[string]jsonPermission{}
|
||||||
|
if err := json.Unmarshal(b, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
if ps.kernelPerms == nil {
|
||||||
|
ps.kernelPerms = kernelPermissionSet{}
|
||||||
|
}
|
||||||
|
for name, jp := range raw {
|
||||||
|
ps.kernelPerms[name] = jp.unmarshal()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveKernelPermissionsLocked flushes the grant map. Caller holds ps.mu.
|
||||||
|
//
|
||||||
|
// Grants drop unknown action names on write. If an operator authorized an action
|
||||||
|
// that a later release removed, keeping the name would leave a stale entry that
|
||||||
|
// nothing can ever satisfy — and it would reappear in the UI as if it were live.
|
||||||
|
func (ps *Plugins) saveKernelPermissionsLocked() {
|
||||||
|
path := ps.kernelPermsFile()
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := map[string]jsonPermission{}
|
||||||
|
for name, perm := range ps.kernelPerms {
|
||||||
|
valid := make([]string, 0, len(perm.Actions))
|
||||||
|
for _, a := range perm.Actions {
|
||||||
|
if _, ok := LookupKernelAction(a); ok {
|
||||||
|
valid = append(valid, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(valid) == 0 && !perm.AllRead {
|
||||||
|
continue // nothing granted: presence would be a lie
|
||||||
|
}
|
||||||
|
out[name] = KernelPermission{Actions: valid, AllRead: perm.AllRead}.marshal()
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONAtomic(path, out)
|
||||||
|
}
|
||||||
245
internal/lua/plugin_bridges.go
Normal file
245
internal/lua/plugin_bridges.go
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
package lua
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
279
internal/lua/plugin_secrets.go
Normal file
279
internal/lua/plugin_secrets.go
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
package lua
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Package lua — 插件凭据保险库(R4-d)
|
||||||
|
//
|
||||||
|
// 用户点明的能力:插件**自己主导外部源的链接认证、自动领取额度**。这类插件
|
||||||
|
// (对照 dsh-our-free-model 的渠道插件形态)要长期持有 OAuth token、session、
|
||||||
|
// 设备指纹等凭据,并在定时任务里拿去换额度、换上游。
|
||||||
|
//
|
||||||
|
// 在保险库之前,插件唯一的持久化落点是 plugin.state,而它有两个问题:
|
||||||
|
//
|
||||||
|
// 1. **权限**:.state.json 是业务累计量(计费总额、签到天数),和凭据混在同一个
|
||||||
|
// 文件里。它没有 0600,落在插件目录(0755)下 —— 存 token 等于把凭据摊开。
|
||||||
|
// 2. **语义**:state 会被 PUT /api/plugins/{name}/state 整体替换,会被
|
||||||
|
// `prices` 分离逻辑触碰,会进 WebUI。凭据进去就有可能被一次"重置状态"
|
||||||
|
// 或一次 UI 回显带走。
|
||||||
|
//
|
||||||
|
// 保险库把凭据隔离成独立文件:
|
||||||
|
//
|
||||||
|
// <plugin_dir>/.secrets/<plugin>.json 权限 0600,目录 0700
|
||||||
|
//
|
||||||
|
// 与状态分离,不参与 UI 回显,不参与 state 替换。
|
||||||
|
//
|
||||||
|
// # 谁能读
|
||||||
|
//
|
||||||
|
// 只有该插件自己(按启动时的固定名字)。跨插件读会被拒 —— 记名方式是插件名
|
||||||
|
// 而非任意引用,因为参考插件的 `credentialRef` 模式(任意字符串引用)在这里
|
||||||
|
// 会变成一个可以指向别人凭据的键空间。
|
||||||
|
//
|
||||||
|
// # 与内核动作的关系
|
||||||
|
//
|
||||||
|
// 凭据本身不进 Lua 的返回值上限之外的地方:取出后由插件用于自己的 HTTP 调用。
|
||||||
|
// 保险库不把凭据交给内核,内核也不需要读它。
|
||||||
|
|
||||||
|
// secretsDirName is the vault directory inside plugin_dir. The leading dot keeps
|
||||||
|
// it out of a casual `ls` of the plugin directory, which is where operators look
|
||||||
|
// for "what plugins do I have" — the vault is not a plugin.
|
||||||
|
const secretsDirName = ".secrets"
|
||||||
|
|
||||||
|
// secretsDir returns the vault directory (empty when there is no plugin dir, in
|
||||||
|
// which case the vault is disabled — same posture as state persistence).
|
||||||
|
func (ps *Plugins) secretsDir() string {
|
||||||
|
if ps.dir == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(ps.dir, secretsDirName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretFile returns one plugin's vault path.
|
||||||
|
func (ps *Plugins) secretFile(plugin string) string {
|
||||||
|
d := ps.secretsDir()
|
||||||
|
if d == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(d, plugin+".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSecretKey rejects keys that could not round-trip through the vault.
|
||||||
|
//
|
||||||
|
// The vault is a flat string->string map serialized as JSON, so a key only needs
|
||||||
|
// to be non-empty and not absurdly long. Traversal is not a concern because keys
|
||||||
|
// are never used as path components; the FILE name comes from the plugin name,
|
||||||
|
// which is already validated when the plugin loads.
|
||||||
|
func validateSecretKey(key string) error {
|
||||||
|
if strings.TrimSpace(key) == "" {
|
||||||
|
return fmt.Errorf("secret key must not be empty")
|
||||||
|
}
|
||||||
|
if len(key) > 200 {
|
||||||
|
return fmt.Errorf("secret key too long (%d chars, max 200)", len(key))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSecretsLocked reads one plugin's vault. Caller holds ps.mu.
|
||||||
|
//
|
||||||
|
// A missing file means "nothing stored yet" — the normal first-run state. A
|
||||||
|
// corrupt file returns an error rather than an empty map: silently treating
|
||||||
|
// unreadable credentials as absent would make a plugin re-login and overwrite
|
||||||
|
// whatever the file still held, which is how a recoverable parse problem becomes
|
||||||
|
// a lost session.
|
||||||
|
func (ps *Plugins) loadSecretsLocked(plugin string) (map[string]string, error) {
|
||||||
|
path := ps.secretFile(plugin)
|
||||||
|
if path == "" {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := map[string]string{}
|
||||||
|
if len(b) == 0 {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("vault for plugin %s is unreadable: %w", plugin, err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSecretsLocked persists one plugin's vault with 0600.
|
||||||
|
//
|
||||||
|
// The mode is set on the temp file BEFORE the rename, so the secret is never
|
||||||
|
// visible under a looser mode at any instant — a chmod-after-write would leave a
|
||||||
|
// window where the token sits in a world-readable file.
|
||||||
|
func (ps *Plugins) writeSecretsLocked(plugin string, m map[string]string) error {
|
||||||
|
path := ps.secretFile(plugin)
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("no plugin directory configured; the credential vault is disabled")
|
||||||
|
}
|
||||||
|
dir := ps.secretsDir()
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Tighten an existing directory too: MkdirAll is a no-op when it exists, so a
|
||||||
|
// vault created before this mode was enforced would keep its old permissions.
|
||||||
|
_ = os.Chmod(dir, 0o700)
|
||||||
|
|
||||||
|
b, err := json.MarshalIndent(m, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(dir, plugin+".*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
cleanup := func() { _ = os.Remove(tmpName) }
|
||||||
|
if err := tmp.Chmod(0o600); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tmp.Write(b); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 导出给 Lua 的 API ----
|
||||||
|
|
||||||
|
// SetSecret stores one credential for a plugin.
|
||||||
|
func (ps *Plugins) SetSecret(plugin, key, value string) error {
|
||||||
|
if plugin == "" {
|
||||||
|
return fmt.Errorf("plugin name required")
|
||||||
|
}
|
||||||
|
if err := validateSecretKey(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
m, err := ps.loadSecretsLocked(plugin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m[key] = value
|
||||||
|
return ps.writeSecretsLocked(plugin, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecret reads one credential. The second return reports whether it existed,
|
||||||
|
// which lets a plugin distinguish "no token stored yet" from "token is empty"
|
||||||
|
// without a sentinel value.
|
||||||
|
func (ps *Plugins) GetSecret(plugin, key string) (string, bool, error) {
|
||||||
|
if plugin == "" {
|
||||||
|
return "", false, fmt.Errorf("plugin name required")
|
||||||
|
}
|
||||||
|
if err := validateSecretKey(key); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
ps.mu.RLock()
|
||||||
|
defer ps.mu.RUnlock()
|
||||||
|
m, err := ps.loadSecretsLocked(plugin)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
v, ok := m[key]
|
||||||
|
return v, ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSecret removes one credential. Removing a missing key is not an error —
|
||||||
|
// "make sure nothing is stored under this key" is the useful postcondition.
|
||||||
|
func (ps *Plugins) DeleteSecret(plugin, key string) error {
|
||||||
|
if plugin == "" {
|
||||||
|
return fmt.Errorf("plugin name required")
|
||||||
|
}
|
||||||
|
if err := validateSecretKey(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
m, err := ps.loadSecretsLocked(plugin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(m, key)
|
||||||
|
return ps.writeSecretsLocked(plugin, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretKeys lists the KEY NAMES a plugin has stored (never the values), so a
|
||||||
|
// plugin can show "which accounts are configured" without exposing token bytes.
|
||||||
|
func (ps *Plugins) SecretKeys(plugin string) ([]string, error) {
|
||||||
|
if plugin == "" {
|
||||||
|
return nil, fmt.Errorf("plugin name required")
|
||||||
|
}
|
||||||
|
ps.mu.RLock()
|
||||||
|
defer ps.mu.RUnlock()
|
||||||
|
m, err := ps.loadSecretsLocked(plugin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAllSecrets wipes a plugin's vault. Used on plugin removal so uninstalling
|
||||||
|
// a plugin does not leave live credentials behind.
|
||||||
|
func (ps *Plugins) DeleteAllSecrets(plugin string) {
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
if path := ps.secretFile(plugin); path != "" {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeOrphanVaults removes vault files whose plugin no longer exists.
|
||||||
|
//
|
||||||
|
// Vaults outlive the plugin file only if a removal path forgot to clean up, so
|
||||||
|
// this is a startup sweep rather than a hot path. Its value is that a deleted
|
||||||
|
// plugin cannot leave a live OAuth token on disk indefinitely.
|
||||||
|
func (ps *Plugins) PurgeOrphanVaults() {
|
||||||
|
dir := ps.secretsDir()
|
||||||
|
if dir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ps.mu.RLock()
|
||||||
|
live := make(map[string]bool, len(ps.plugins))
|
||||||
|
for _, p := range ps.plugins {
|
||||||
|
live[p.Info.Name] = true
|
||||||
|
}
|
||||||
|
ps.mu.RUnlock()
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(e.Name(), ".json")
|
||||||
|
if live[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -244,6 +244,11 @@ type Plugin struct {
|
|||||||
// was supposed to inherit from.
|
// was supposed to inherit from.
|
||||||
persisted bool
|
persisted bool
|
||||||
|
|
||||||
|
// KernelDeclared lists the kernel actions this plugin asks for in its own
|
||||||
|
// source (`plugin.kernel = {...}`). It is a REQUEST, shown to the operator
|
||||||
|
// beside what was actually granted; it grants nothing by itself.
|
||||||
|
KernelDeclared []string
|
||||||
|
|
||||||
// Schedule is the periodic work this plugin declared, and Runtime is the
|
// Schedule is the periodic work this plugin declared, and Runtime is the
|
||||||
// scheduler that runs it on THIS plugin's own VM (p.state) under p.mu — the
|
// scheduler that runs it on THIS plugin's own VM (p.state) under p.mu — the
|
||||||
// same state and the same lock a request hook uses. That is what makes a
|
// same state and the same lock a request hook uses. That is what makes a
|
||||||
@ -293,6 +298,15 @@ type Plugins struct {
|
|||||||
// nil means deny-all: an unconfigured gateway must not silently grant a
|
// nil means deny-all: an unconfigured gateway must not silently grant a
|
||||||
// plugin unrestricted network access.
|
// plugin unrestricted network access.
|
||||||
allowHTTP *httpAllowlist
|
allowHTTP *httpAllowlist
|
||||||
|
// kernelAPI is the injected kernel action surface (nil when unwired).
|
||||||
|
// See kernel_api.go: a plugin calling __llmsproxy_kernel reaches the core
|
||||||
|
// through this interface, never through a direct import (core depends on
|
||||||
|
// lua, not the other way round).
|
||||||
|
kernelAPI KernelAPI
|
||||||
|
// kernelPerms records what the OPERATOR granted each plugin. Separate from
|
||||||
|
// what a plugin DECLARES it wants (Plugin.KernelDeclared): a declaration is
|
||||||
|
// a request, a grant is a privilege. Persisted in .access.json.
|
||||||
|
kernelPerms kernelPermissionSet
|
||||||
// save coalesces state writes into one background goroutine.
|
// save coalesces state writes into one background goroutine.
|
||||||
//
|
//
|
||||||
// A hook must NOT write synchronously: the billing plugin mutates state on
|
// A hook must NOT write synchronously: the billing plugin mutates state on
|
||||||
@ -543,6 +557,12 @@ func (ps *Plugins) LoadDir() error {
|
|||||||
if err := ps.LoadAccessOverrides(); err != nil {
|
if err := ps.LoadAccessOverrides(); err != nil {
|
||||||
log.Printf("[core] plugin access overrides not loaded: %v", err)
|
log.Printf("[core] plugin access overrides not loaded: %v", err)
|
||||||
}
|
}
|
||||||
|
// Kernel grants load beside them and for the same reasons: an in-memory map
|
||||||
|
// would make "this plugin may reload config" look enforced right up to the
|
||||||
|
// next restart, at which point the grant silently vanished.
|
||||||
|
if err := ps.LoadKernelPermissions(); err != nil {
|
||||||
|
log.Printf("[core] plugin kernel permissions not loaded: %v", err)
|
||||||
|
}
|
||||||
if _, err := os.Stat(ps.dir); os.IsNotExist(err) {
|
if _, err := os.Stat(ps.dir); os.IsNotExist(err) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -609,6 +629,12 @@ func bootPluginState(code, name string, allow *httpAllowlist, ps *Plugins) (*wor
|
|||||||
// as __llmsproxy_http: adapters share setupGlobals and must not get it.
|
// as __llmsproxy_http: adapters share setupGlobals and must not get it.
|
||||||
if ps != nil {
|
if ps != nil {
|
||||||
registerAdapterBridge(L, ps, name)
|
registerAdapterBridge(L, ps, name)
|
||||||
|
// R4-d: the credential vault and the kernel action surface. Both are
|
||||||
|
// plugin-only, for the same reason as the bridges above — an adapter
|
||||||
|
// shares setupGlobals and must not be able to read credentials or drive
|
||||||
|
// the kernel.
|
||||||
|
registerSecretBridge(L, ps, name)
|
||||||
|
registerKernelBridge(L, ps, name)
|
||||||
}
|
}
|
||||||
if err := L.DoString(code); err != nil {
|
if err := L.DoString(code); err != nil {
|
||||||
L.Close()
|
L.Close()
|
||||||
@ -743,6 +769,13 @@ func (ps *Plugins) LoadSource(name, code string) error {
|
|||||||
p.UI = ui
|
p.UI = ui
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- declared kernel requests ----
|
||||||
|
// A declaration is a REQUEST, not a grant (see kernel_api.go). Reading it at
|
||||||
|
// load time lets the settings page show "this plugin wants add_source"
|
||||||
|
// beside what was actually granted.
|
||||||
|
p.KernelDeclared = readPluginKernelDeclared(w.L)
|
||||||
|
w.L.SetTop(0)
|
||||||
|
|
||||||
// ---- http endpoints ----
|
// ---- http endpoints ----
|
||||||
p.http = readPluginHTTP(w.L)
|
p.http = readPluginHTTP(w.L)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user