Files
ModelRouter/internal/gateway/browser_proxy.go
JianFeeeee fa50ec8bc7 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 契约。
2026-10-11 14:26:28 +08:00

111 lines
3.9 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 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:])
}