Files
ModelRouter/internal/lua/kernel_api.go
JianFeeeee c3b858d5ba feat(lua): 多流程登录会话抽象(R4-e)+ 内核权限设置页
用户决策:**多流程抽象**(VNC 作逃生舱)+ **单插件多平台**。

## 为什么不是 VNC 为主路径(实测参考插件后的结论)

grep 参考插件(dsh-our-free-model,13 个账号渠道)的源码:

  puppeteer / playwright / chromium / headless / vnc / selenium  →  0 处

它的登录全部是**把 URL 交给用户自己的浏览器**,插件轮询凭据是否就绪:
loginUrl 113 次 · cookie 133 次 · oauth 43 次 · deviceCode 8 次 · qrcode 7 次。

不以 VNC 为主路径的四条具体理由:
1. Passkey/WebAuthn 基本不可用:凭据绑在用户设备,VNC 里是另一台机器。
2. 无头浏览器更容易被判为机器人 —— 登录场景最不能踩这个。
3. 安全面:llmsproxy 以 root 运行(master.key 0600),systemd 已 ProtectHome=yes。
   从 root 服务开 VNC 通道到"即将登录用户各平台账号"的浏览器 = 提权面绑高价值目标。
4. 最关键:VNC 不解决取凭据。登录后凭据活在浏览器 profile 的 cookie 里,
   还是要提取 profile —— 那正是最脆弱的一环。轮询流程直接产出 token。

VNC 唯一不可替代的场景(服务端与用户不同网 + 平台要求登录 IP 与调用 IP 一致)
使它作为逃生舱存在:**默认关闭**,需运维显式开启。

## 登录会话抽象(plugin_login.go)

四种流程:link-poll(首选)· callback(复用 Phase 6 自定义挂载)· paste(兜住
cookie 类渠道)· vnc(逃生舱)。内核只管会话生命周期,不含任何平台知识。

- 会话只存**状态**不存凭据:凭据由插件写进自己的保险库,会话里放一个
  UI 可见的记录会让 token 有机会被列表接口带出去
- nonce 只在 begin 时给(后续读取剥离),空 nonce 不匹配任何会话
- 读取时判定过期,无后台定时器(没有登录请求时零开销)
- 跨插件读不可行,且与"不存在"不可区分(否则会确认 id 存在)

## 关键阻碍与修法:登录主机不在出站白名单里

**实测确认**:`auth.huaweicloud.com` 被白名单拒绝
("not an allowed outbound destination (allowed: the hosts of configured sources)"),
整个登录流程无法工作 —— 因为登录主机不是上游源。

修法与内核权限同一姿态:插件在源码 `plugin.auth_hosts` **声明**,运维在设置页
**批准**后才放行。只能批准插件声明过的 host(否则声明只是装饰),逐个批准
不连带(否则一个"登录"理由就能打开整张名单)。

## 设置页

插件表格新增"内核动作"列 + 点击展开的权限面板:动作清单按危险级排序、勾选即
授权、**申请中但未授权会标红**(那通常意味着插件某功能静默失效)。
另加登录主机批准面板(已批准/待批准)。

后端 /api/plugins/{name}/kernel(GET 看 declared vs granted,PUT 改)
与 /api/plugins/{name}/login(列在飞会话,供页面显示"等待你完成登录"+ URL)。

开发中自己引入并修掉一个缺陷:renderPlugins → refreshKernelSummary →
renderPlugins 无限递归;改为只原地 patch 受影响的单元格。

测试 8 个:会话生命周期/跨插件隔离/callback nonce/登录主机门控/只能批准已声明/
过期判定/VNC 需显式开启/未知流程名被拒。node --check 验证内联 JS 可解析。
2026-10-11 13:56:35 +08:00

489 lines
19 KiB
Go
Raw 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}
}
// 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
}