Files
ModelRouter/internal/lua/kernel_api.go
JianFeeeee a07449d32e fix(plugins): 出站主机授权两处真缺陷 —— 已启动 worker 看不到新授权、授权不落盘
生产实证:channels 插件的 auth/state 在主机已授权后仍被拒
(auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination),
而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。

缺陷一(授权对已启动 worker 无效):
  httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua
  闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**
  (newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回
  ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。
  修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与
  rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker
  无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。

缺陷二(授权不持久化):
  .kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。
  设置页看不出异常(actions 有持久化,读起来像"都持久化了")。
  修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容,
  纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权
  与 actions 一起写入(支持"只批了主机、没批动作"的行)。

回归测试(internal/lua/plugin_http_test.go):
- TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动
  worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。
- TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载
  后授权保留。

go test ./internal/lua/ 全绿;build/vet 通过。
2026-10-13 06:57:28 +08:00

499 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}
}
type jsonPluginPolicy struct {
Actions []string `json:"actions,omitempty"`
AllRead bool `json:"all_read,omitempty"`
Hosts []string `json:"hosts,omitempty"`
}
func policyFromActions(p KernelPermission) jsonPluginPolicy {
return jsonPluginPolicy{Actions: p.Actions, AllRead: p.AllRead}
}
// KernelActionView is one action as the settings page needs it: the catalog entry
// plus whether THIS plugin currently holds it and whether the plugin asked for it.
type KernelActionView struct {
KernelAction
Granted bool `json:"granted"`
Declared bool `json:"declared"`
}
// KernelActionsView lists the catalog annotated for one plugin.
//
// Granted and Declared are both reported because they answer different questions:
// "may this plugin do it" and "did this plugin ask for it". An action that is
// declared but not granted is the one an operator most needs to notice.
func (ps *Plugins) KernelActionsView(plugin string) []KernelActionView {
perm := ps.PluginKernelPermission(plugin)
declared := map[string]bool{}
ps.mu.RLock()
for _, p := range ps.plugins {
if p.Info.Name == plugin {
for _, d := range p.KernelDeclared {
declared[d] = true
}
}
}
ps.mu.RUnlock()
out := make([]KernelActionView, 0, len(kernelCatalog))
for _, a := range KernelActions() {
out = append(out, KernelActionView{
KernelAction: a,
Granted: perm.allows(a),
Declared: declared[a.Name],
})
}
return out
}