Files
ModelRouter/internal/lua/plugin_bridges.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

457 lines
12 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 (
"fmt"
"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
}
// loginGlobal is the Lua name of the login-session bridge.
const loginGlobal = "__llmsproxy_login"
// registerLoginBridge installs the login session API.
//
// A plugin drives the whole flow itself (it knows the platform's endpoints) and
// the kernel only owns the session lifecycle: create, poll, resolve, cancel.
// That split is what lets one kernel serve OAuth, device-code, QR and paste
// flows without containing any platform knowledge.
func registerLoginBridge(L *golua.State, ps *Plugins, name string) {
L.NewTable()
L.PushGoClosure(luaLoginBegin(ps, name))
L.SetField(-2, "begin")
L.PushGoClosure(luaLoginGet(ps, name))
L.SetField(-2, "get")
L.PushGoClosure(luaLoginResolve(ps, name))
L.SetField(-2, "resolve")
L.PushGoClosure(luaLoginList(ps, name))
L.SetField(-2, "list")
L.PushGoClosure(luaLoginCancel(ps, name))
L.SetField(-2, "cancel")
L.SetGlobal(loginGlobal)
}
// luaLoginBegin: begin({platform, flow, url, user_code}) -> (session, nil) | (nil, err)
func luaLoginBegin(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
args, err := luaTableArg(L, 1)
if err != nil {
L.PushNil()
L.PushString("__llmsproxy_login.begin: " + err.Error())
return 2
}
s := LoginSession{
Plugin: name,
Platform: asString(args["platform"]),
Flow: LoginFlow(asString(args["flow"])),
URL: asString(args["url"]),
UserCode: asString(args["user_code"]),
}
out, err := ps.BeginLogin(s)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
pushGoValue(L, sessionToLua(*out, true))
L.PushNil()
return 2
}
}
// luaLoginGet: get(id) -> (session, nil) | (nil, err)
func luaLoginGet(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
id := L.ToString(1)
s, ok := ps.GetLogin(id)
if !ok {
L.PushNil()
L.PushNil() // absent is not an error; the plugin tests for nil
return 2
}
if s.Plugin != name {
// Another plugin's session is indistinguishable from a missing one.
// Reporting "not yours" would confirm the id exists.
L.PushNil()
L.PushNil()
return 2
}
pushGoValue(L, sessionToLua(*s, false))
L.PushNil()
return 2
}
}
// luaLoginResolve: resolve(id, state, account, message) -> (true, nil) | (nil, err)
func luaLoginResolve(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
id := L.ToString(1)
state := LoginSessionState(L.ToString(2))
account := L.ToString(3)
message := L.ToString(4)
s, ok := ps.GetLogin(id)
if !ok || s.Plugin != name {
L.PushNil()
L.PushString("no such login session")
return 2
}
if err := ps.ResolveLogin(id, state, account, message); err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
L.PushBoolean(true)
L.PushNil()
return 2
}
}
// luaLoginList: list() -> (array of sessions, nil)
func luaLoginList(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
sessions := ps.ListLogins(name)
L.NewTable()
for i, s := range sessions {
pushGoValue(L, sessionToLua(s, false))
L.RawSeti(-2, i+1)
}
L.PushNil()
return 2
}
}
// luaLoginCancel: cancel(id) -> (true, nil)
func luaLoginCancel(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
id := L.ToString(1)
s, ok := ps.GetLogin(id)
if ok && s.Plugin == name {
_ = ps.ResolveLogin(id, LoginCancelled, "", "cancelled by the plugin")
}
L.PushBoolean(true)
L.PushNil()
return 2
}
}
// sessionToLua renders a session for Lua. The nonce is included ONLY on begin
// (the plugin needs it to build its redirect_uri); subsequent reads omit it so a
// plugin that logs or forwards a session cannot leak the CSRF token.
func sessionToLua(s LoginSession, includeNonce bool) map[string]interface{} {
out := map[string]interface{}{
"id": s.ID,
"platform": s.Platform,
"flow": string(s.Flow),
"state": string(s.State),
"url": s.URL,
"user_code": s.UserCode,
"message": s.Message,
"account": s.Account,
"created_at": s.CreatedAt.Unix(),
"expires_at": s.ExpiresAt.Unix(),
}
if includeNonce {
out["nonce"] = s.Nonce
}
return out
}
// luaTableArg decodes a Lua table argument into a Go map, reporting a clear error
// when the caller passed something else (a common mistake that would otherwise
// surface as a silent zero value).
func luaTableArg(L *golua.State, idx int) (map[string]interface{}, error) {
if L.Type(idx) != golua.LUA_TTABLE {
return nil, fmt.Errorf("expected a table argument, got %s", normalizeString(L, idx))
}
var raw interface{}
if err := luaToJSON(L, idx, &raw); err != nil {
return nil, err
}
m, ok := raw.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("argument must be a keyed table")
}
return m, nil
}
// readPluginAuthHosts reads `plugin.auth_hosts`, the login hosts a plugin needs.
//
// plugin.auth_hosts = { "auth.example.com", "accounts.google.com" }
//
// A list of bare hostnames — not URLs — because the allowlist matches on host,
// and accepting full URLs would invite a plugin to smuggle a path or scheme that
// the operator did not read. Entries are normalised so the granted and declared
// forms always compare equal.
func readPluginAuthHosts(L *golua.State) []string {
raw, ok := pluginFieldValue(L, "auth_hosts")
if !ok {
return nil
}
items, isList := raw.([]interface{})
if !isList {
if one, isTable := raw.(map[string]interface{}); isTable {
if v, ok := one["hosts"].([]interface{}); ok {
items = v
} else {
return nil
}
} else {
return nil
}
}
out := make([]string, 0, len(items))
seen := map[string]bool{}
for _, it := range items {
s, ok := it.(string)
if !ok {
continue
}
h := normalizeHost(s)
if h == "" || seen[h] {
continue
}
seen[h] = true
out = append(out, h)
}
sort.Strings(out)
return out
}