mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-09 18:57:21 +00:00
用户决策:**多流程抽象**(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 可解析。
457 lines
12 KiB
Go
457 lines
12 KiB
Go
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
|
||
}
|