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} }