mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 12:17: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:
110
internal/gateway/browser_proxy.go
Normal file
110
internal/gateway/browser_proxy.go
Normal file
@ -0,0 +1,110 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 服务端浏览器 viewer 的反向代理(R4-f)
|
||||
//
|
||||
// 用户指明的场景:有些平台的登录会**重定向到本机回环地址**,用户机器上的浏览器
|
||||
// 收不到(那个 127.0.0.1 是用户自己的),所以必须在服务端跑浏览器。而那个浏览器
|
||||
// 需要给用户一个能操作的界面 —— 这就是本文件。
|
||||
//
|
||||
// 架构:Xvfb(:N) ← chromium;x11vnc 把 :N 暴露成 VNC(:v);websockify 把 VNC 桥成
|
||||
// WebSocket 并顺带伺服 noVNC 静态文件(:w)。网关把 :w 反代到
|
||||
// /browser/<sessionID>/...
|
||||
//
|
||||
// 为什么反代而不是让用户直连 :w:
|
||||
//
|
||||
// 1. **鉴权**。直连等于把一个"已经登录着用户第三方账号"的浏览器暴露在网络上,
|
||||
// 任何能访问该端口的人都能接着用那个会话。反代让它继承网关的登录态与角色。
|
||||
// 2. **WebSocket**。noVNC 的核心是 WS 升级;httputil.ReverseProxy 从 Go 1.12
|
||||
// 起原生处理 Upgrade,自己写会把这块写错。
|
||||
// 3. **同源**。viewer 与网关同源,noVNC 的 WS 路径不需要跨域配置。
|
||||
|
||||
// browserMountPrefix 是 viewer 的挂载前缀。
|
||||
const browserMountPrefix = "/browser/"
|
||||
|
||||
// isBrowserMount 判断请求是否属于 viewer。
|
||||
func isBrowserMount(p string) bool { return strings.HasPrefix(p, browserMountPrefix) }
|
||||
|
||||
// handleBrowserMount 把 /browser/<id>/... 反代到该会话的 websockify。
|
||||
//
|
||||
// 鉴权沿用网关的通用路径(Handler 外层已做 auth),因此只有已登录的运维能打开;
|
||||
// 这里额外要求 admin 角色:能操作一个已登录浏览器的人等价于能操作被登录的账号。
|
||||
func (g *Gateway) handleBrowserMount(w http.ResponseWriter, r *http.Request) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(r.URL.Path, browserMountPrefix)
|
||||
id := rest
|
||||
if i := strings.Index(rest, "/"); i >= 0 {
|
||||
id = rest[:i]
|
||||
}
|
||||
if id == "" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "browser session id required")
|
||||
return
|
||||
}
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
|
||||
return
|
||||
}
|
||||
sess, ok := ps.GetBrowserSession(id)
|
||||
if !ok {
|
||||
// An expired session is torn down on read, so "not found" is the honest
|
||||
// answer: the browser and its cookies are gone.
|
||||
writeError(w, http.StatusNotFound, "not_found", "no such browser session (it may have expired)")
|
||||
return
|
||||
}
|
||||
if sess.WSPort == 0 {
|
||||
writeError(w, http.StatusInternalServerError, "internal", "browser session has no viewer port")
|
||||
return
|
||||
}
|
||||
|
||||
target := &url.URL{Scheme: "http", Host: "127.0.0.1:" + itoa(sess.WSPort)}
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
|
||||
// The session id is stripped from the path before proxying, so websockify
|
||||
// sees the SAME paths it serves locally ("/vnc.html", "/websockify").
|
||||
// Without this the viewer's bundled JS would build a WS URL containing the
|
||||
// gateway prefix and websockify would 404 the upgrade.
|
||||
orig := r.URL.Path
|
||||
prefixLen := len(browserMountPrefix) + len(id)
|
||||
if prefixLen < len(orig) {
|
||||
r.URL.Path = orig[prefixLen:]
|
||||
} else {
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
r.URL.RawPath = ""
|
||||
// Preserve the browser-facing Host so noVNC's relative URLs stay on the
|
||||
// gateway origin.
|
||||
r.Header.Set("X-Forwarded-Prefix", browserMountPrefix+id)
|
||||
|
||||
// A failed upgrade is the common failure (a stale port after teardown), and
|
||||
// the default proxy error page says nothing useful.
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
writeError(w, http.StatusBadGateway, "browser_unavailable",
|
||||
"the browser viewer is not reachable: "+err.Error())
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// itoa is a tiny integer formatter for port numbers.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
69
internal/gateway/browser_proxy_test.go
Normal file
69
internal/gateway/browser_proxy_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestBrowserViewerIsProxiedAndAuthenticated 验证服务端浏览器 viewer 的反代。
|
||||
//
|
||||
// viewer 一定要经网关反代而不是让用户直连 websockify 端口:
|
||||
// - 直连等于把一个"已登录用户第三方账号"的浏览器暴露在网络上
|
||||
// - noVNC 的核心是 WebSocket 升级,反代由 httputil 原生处理
|
||||
// - 同源让 noVNC 的 WS 路径不需要跨域配置
|
||||
func TestBrowserViewerIsProxiedAndAuthenticated(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
c := g.core
|
||||
ps := c.Plugins()
|
||||
if ps == nil {
|
||||
t.Fatal("无 plugins")
|
||||
}
|
||||
ps.LoadSource("bp", `local plugin = { name = "bp", version = "1.0" } return plugin`)
|
||||
ps.SetVNCEnabled(true)
|
||||
sess, err := ps.StartBrowserSession("bp", "example", "about:blank", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("启动浏览器会话: %v", err)
|
||||
}
|
||||
defer ps.StopBrowserSession(sess.ID)
|
||||
time.Sleep(4 * time.Second) // 让 websockify 起来
|
||||
|
||||
ts := httptest.NewServer(g.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
// 不带 key → 401(viewer 继承网关鉴权)
|
||||
// 关键:不要跟随重定向,否则拿到的是登录页(200)而不是真实判定
|
||||
noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
resp, err := noFollow.Get(ts.URL + sess.ViewURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
t.Logf("未鉴权访问: status=%d location=%q", resp.StatusCode, resp.Header.Get("Location"))
|
||||
if resp.StatusCode == 200 {
|
||||
t.Errorf("★ 未鉴权访问 viewer 竟然 200")
|
||||
}
|
||||
|
||||
// 带 admin key → 应拿到 noVNC 页面
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/browser/"+sess.ID+"/vnc.html", nil)
|
||||
req.Header.Set("Authorization", "Bearer sk-test")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := resp.Body.Read(buf)
|
||||
body := string(buf[:n])
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("viewer 应 200, 实得 %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(body), "novnc") && !strings.Contains(body, "<html") {
|
||||
t.Errorf("viewer 内容不像 noVNC 页面: %s", body[:200])
|
||||
}
|
||||
t.Logf("viewer 返回 %d 字节, 首行: %s", n, strings.SplitN(body, "\n", 2)[0])
|
||||
}
|
||||
@ -260,6 +260,11 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleCustomMount(w, r)
|
||||
case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"):
|
||||
g.handlePluginsAPI(w, r)
|
||||
case isBrowserMount(r.URL.Path):
|
||||
// Server-side browser viewer (VNC-over-WebSocket via noVNC). Checked
|
||||
// before the /api catch-alls because its path is outside them, and
|
||||
// before the UI catch-all so it is not served as a static file.
|
||||
g.handleBrowserMount(w, r)
|
||||
case r.URL.Path == "/api/chat":
|
||||
g.handleChat(w, r)
|
||||
case r.URL.Path == "/api/status":
|
||||
|
||||
Reference in New Issue
Block a user