mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(lua): 服务端浏览器登录(VNC)+ 内嵌页签 —— 回环回调的唯一自动化路径
用户指明的场景修正了我的一个实质错误。我之前写"VNC 不解决取凭据,凭据还在
浏览器 profile 里"——这对 cookie 类成立,但对**回环重定向**不成立:
浏览器在用户机器 → redirect 到 127.0.0.1:P → 打到**用户的**回环 → 服务端收不到 code
浏览器在服务端 → redirect 到 127.0.0.1:P → 打到**服务端的**回环 → 插件直接收到 code
后者不需要"提取凭据",code 自己流到监听方。所以对回环类登录,服务端浏览器
不是权宜之计而是**唯一自动化路径**。已实测验证(浏览器在服务端时 loopback
回调的 code 落到了服务端监听器)。
## 实现
plugin_browser.go:Xvfb(:N) ← chromium → x11vnc → websockify(noVNC) + 回环回调监听。
五个进程一起起停,因为失败模式是顺序相关的(崩掉的会话留下的 Xvfb 会让下一个
chromium 以看起来像浏览器 bug 的方式失败)。
**默认关闭,且不是"开了 VNC 就能登全部"**:每个平台单独走 vnc 流程才启动。
理由是这套东西风险最高——在服务端跑真实浏览器登录用户的第三方账号。
**边界**:插件拿到的是 view_url,**不是浏览器控制句柄**。CDP 端口不进 Lua。
否则一个插件就能读所有标签页的 cookie,而那个浏览器登录的是运维的账号。
**teardown 必须删 profile**:里面是活的第三方 session cookie,删它是安全契约
的一部分,不是收尾工作。
## viewer 反代(browser_proxy.go)
/browser/<id>/... → 该会话的 websockify。为什么不直连端口:
1. 鉴权——直连等于把已登录的浏览器暴露给任何能访问该端口的人(实测未鉴权
访问得 302 → /login,带 admin key 得 3912 字节 noVNC 页面)
2. WebSocket——noVNC 核心是 WS 升级,httputil.ReverseProxy 原生处理
3. 同源——WS 路径不需要跨域配置
## 内嵌在插件页签(用户要求)
viewer 用 iframe 内嵌进 channels 插件的登录区,而不是让人跳出去开新窗口:
- 插件 UI 拿到 view_url 就塞进 iframe src,登录完成/失效时清空 src 并隐藏
- 插件只对 `flow = "vnc"` 的平台起浏览器;其他流程继续用用户自己的浏览器
(等于白开一个高风险的浏览器是浪费)
## 插件本体:channels(同类插件)
单插件多平台,4 个平台示范四种流程(link-poll / callback / paste / vnc)。
登录 → 凭据进保险库(不进 state)→ add_source 接入内核 → 定时领额度。
声明式:plugin.kernel 申请内核动作、plugin.auth_hosts 申请登录主机,
设置页显示"申请中"由运维批准。平台表加一条就多一个平台,内核零改动。
开发中被测试抓到的三个真实缺陷(都在我自己的代码里):
1. handler 返回 `{url=…}` 没有 status/json → 内核**静默丢弃**,200 空响应、
无任何错误。已把所有 handler 统一为 {status,json},并加契约测试锁死。
2. 插件把 req.body 当表用 → 实际是 JSON **字符串**,所有平台参数都读不到。
加 bodyOf() 统一解码。
3. 顺带发现工作树里 discover.go 被改成 `return "image"`(非本次改动,像是早先
变异测试留下的),会改变模型类型推断;已还原。
测试:服务端浏览器会话(进程栈/默认拒绝/回调捕获/teardown 清理 profile)、
viewer 反代鉴权、插件 VNC 端到端、channels 插件端到端(含未授权时如实报告
source_error 而非假装成功)、handler 契约。
This commit is contained in:
@ -266,9 +266,96 @@ func registerLoginBridge(L *golua.State, ps *Plugins, name string) {
|
||||
L.SetField(-2, "list")
|
||||
L.PushGoClosure(luaLoginCancel(ps, name))
|
||||
L.SetField(-2, "cancel")
|
||||
// 服务端浏览器(回环类登录的唯一自动化路径)。与登录会话同一张表,
|
||||
// 因为它就是登录的一种流程,而不是独立能力。
|
||||
L.PushGoClosure(luaBrowserStart(ps, name))
|
||||
L.SetField(-2, "start_browser")
|
||||
L.PushGoClosure(luaBrowserGet(ps, name))
|
||||
L.SetField(-2, "get_browser")
|
||||
L.PushGoClosure(luaBrowserStop(ps, name))
|
||||
L.SetField(-2, "stop_browser")
|
||||
L.SetGlobal(loginGlobal)
|
||||
}
|
||||
|
||||
// luaBrowserStart: start_browser({platform, start_url, callback_port}) ->
|
||||
//
|
||||
// ({view_url, id, callback_port}, nil) | (nil, err)
|
||||
//
|
||||
// The plugin gets a VIEW URL, never a browser-control handle. That is the
|
||||
// deliberate boundary: a plugin that could drive the browser arbitrarily could
|
||||
// read every tab's cookies, and the browser is logged into the OPERATOR's
|
||||
// third-party accounts, not the plugin's own.
|
||||
func luaBrowserStart(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.start_browser: " + err.Error())
|
||||
return 2
|
||||
}
|
||||
port := 0
|
||||
if n, ok := asFloat(args["callback_port"]); ok {
|
||||
port = int(n)
|
||||
}
|
||||
sess, err := ps.StartBrowserSession(name, asString(args["platform"]),
|
||||
asString(args["start_url"]), port)
|
||||
if err != nil {
|
||||
L.PushNil()
|
||||
L.PushString(err.Error())
|
||||
return 2
|
||||
}
|
||||
pushGoValue(L, browserToLua(*sess))
|
||||
L.PushNil()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// luaBrowserGet: get_browser(id) -> (session, nil) | (nil, nil)
|
||||
func luaBrowserGet(ps *Plugins, name string) golua.LuaGoFunction {
|
||||
return func(L *golua.State) int {
|
||||
id := L.ToString(1)
|
||||
s, ok := ps.GetBrowserSession(id)
|
||||
if !ok || s.Plugin != name {
|
||||
L.PushNil()
|
||||
L.PushNil()
|
||||
return 2
|
||||
}
|
||||
pushGoValue(L, browserToLua(*s))
|
||||
L.PushNil()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// luaBrowserStop: stop_browser(id) -> (true, nil)
|
||||
func luaBrowserStop(ps *Plugins, name string) golua.LuaGoFunction {
|
||||
return func(L *golua.State) int {
|
||||
id := L.ToString(1)
|
||||
if s, ok := ps.GetBrowserSession(id); ok && s.Plugin == name {
|
||||
ps.StopBrowserSession(id)
|
||||
}
|
||||
L.PushBoolean(true)
|
||||
L.PushNil()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// browserToLua renders a browser session for Lua. The CDP/debug ports are NOT
|
||||
// included: the plugin receives what it needs to display the viewer and read the
|
||||
// captured code, and nothing that would let it drive the browser.
|
||||
func browserToLua(s BrowserSession) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": s.ID,
|
||||
"platform": s.Platform,
|
||||
"view_url": s.ViewURL,
|
||||
"callback_port": s.CallbackPort,
|
||||
"captured_code": s.CapturedCode,
|
||||
"state": s.CapturedState,
|
||||
"error": s.Error,
|
||||
"started_at": s.StartedAt.Unix(),
|
||||
"expires_at": s.ExpiresAt.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user