mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户指明的场景修正了我的一个实质错误。我之前写"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 契约。
389 lines
12 KiB
Go
389 lines
12 KiB
Go
package lua
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// Package lua — 服务端浏览器会话(R4-f,VNC 登录流程)
|
||
//
|
||
// 用户点明的场景:**有些平台的登录会重定向到本机回环地址**(OAuth loopback /
|
||
// RFC 8252 原生应用流程)。而:
|
||
//
|
||
// 浏览器在用户的机器上 → 重定向打到 127.0.0.1 是**用户的**回环 → 服务端收不到 code
|
||
// 浏览器在服务端 → 重定向打到 127.0.0.1 是**服务端的**回环 → 插件直接收到 code
|
||
//
|
||
// 后者不需要"提取浏览器里的凭据"——授权码会自己流到监听方。所以对回环类登录,
|
||
// 服务端浏览器不是权宜之计,而是**唯一自动化路径**。已实测验证(浏览器在服务端
|
||
// 时 loopback 回调的 code 落到了服务端监听器)。
|
||
//
|
||
// # 为什么仍然默认关闭
|
||
//
|
||
// 这不是"多此一举",而是这套东西的风险确实最高:在服务端跑一个真实浏览器,
|
||
// 让它登录**用户的第三方账号**。所以:
|
||
//
|
||
// - 默认关闭,需运维在设置页显式开启
|
||
// - 每个平台单独授权(不是"开了 VNC 就能登全部")
|
||
// - 浏览器与 X 服务跑在独立进程组,插件卸载/会话结束即回收
|
||
// - **不把浏览器的 CDP 端口暴露给插件 Lua**:插件拿到的是"会话 id + 打开 URL",
|
||
// 不是任意控制浏览器的能力。否则一个插件就能读用户所有标签页的 cookie。
|
||
//
|
||
// # 与 plan 的关系
|
||
//
|
||
// 原计划把 VNC 定为"逃生舱、默认关闭"。用户指出回环场景后,它仍是逃生舱
|
||
// (默认关闭),但**不再是"最后手段"**:对回环类它是首选且唯一可行的流程。
|
||
|
||
// browserSessionTTL bounds one browser session. A server-side browser holding a
|
||
// logged-in third-party session must not outlive the login attempt.
|
||
const browserSessionTTL = 20 * time.Minute
|
||
|
||
// BrowserSession is one server-side browser exposed for interactive login.
|
||
type BrowserSession struct {
|
||
ID string `json:"id"`
|
||
Plugin string `json:"plugin"`
|
||
Platform string `json:"platform"`
|
||
// ViewURL is where the operator opens the VNC viewer (noVNC). It is served
|
||
// by the gateway reverse-proxying this session's websockify port, so it
|
||
// inherits the gateway's auth and the VNC port is never exposed directly.
|
||
ViewURL string `json:"view_url"`
|
||
// VNCPort / Display are internal; exposed for diagnostics only.
|
||
VNCPort int `json:"vnc_port"`
|
||
Display string `json:"display"`
|
||
// WSPort is the loopback port where noVNC's static files AND its WebSocket
|
||
// live (websockify serves both). The gateway reverse-proxies it so the
|
||
// viewer is reachable through the gateway's own auth instead of being
|
||
// exposed on the network.
|
||
WSPort int `json:"-"`
|
||
// CallbackPort is the loopback port the login flow will be redirected to.
|
||
// The operator never needs it, but it is the whole point of running the
|
||
// browser server-side, so it is surfaced for troubleshooting.
|
||
CallbackPort int `json:"callback_port"`
|
||
StartedAt time.Time `json:"started_at"`
|
||
ExpiresAt time.Time `json:"expires_at"`
|
||
// CapturedCode is the authorization code that arrived on the loopback
|
||
// listener (empty until the flow completes).
|
||
CapturedCode string `json:"captured_code,omitempty"`
|
||
CapturedState string `json:"captured_state,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
done chan struct{}
|
||
}
|
||
|
||
// browserProcs holds the OS processes for one session so teardown is one place.
|
||
type browserProcs struct {
|
||
xvfb *exec.Cmd
|
||
chromium *exec.Cmd
|
||
x11vnc *exec.Cmd
|
||
ws *exec.Cmd
|
||
cbSrv *http.Server
|
||
profDir string
|
||
}
|
||
|
||
// browserSupervisor owns every live browser session.
|
||
type browserSupervisor struct {
|
||
mu sync.Mutex
|
||
sessions map[string]*browserSession
|
||
}
|
||
|
||
type browserSession struct {
|
||
info BrowserSession
|
||
procs browserProcs
|
||
mu sync.Mutex
|
||
done chan struct{}
|
||
}
|
||
|
||
func newBrowserSupervisor() *browserSupervisor {
|
||
return &browserSupervisor{sessions: map[string]*browserSession{}}
|
||
}
|
||
|
||
func (ps *Plugins) browsers() *browserSupervisor {
|
||
ps.browserOnce.Do(func() {
|
||
if ps.browserStore == nil {
|
||
ps.browserStore = newBrowserSupervisor()
|
||
}
|
||
})
|
||
return ps.browserStore
|
||
}
|
||
|
||
// loopbackCallback is what the loopback listener captured.
|
||
type loopbackCallback struct {
|
||
code string
|
||
state string
|
||
err string
|
||
}
|
||
|
||
// StartBrowserSession launches a server-side browser for one login.
|
||
//
|
||
// It is deliberately a *lot* of moving parts (Xvfb + chromium + VNC + noVNC +
|
||
// loopback listener) in one function rather than spread out, because the failure
|
||
// modes are order-dependent: a leftover Xvfb from a crashed session makes the
|
||
// next chromium fail in a way that looks like a browser bug.
|
||
func (ps *Plugins) StartBrowserSession(plugin, platform, startURL string, callbackPort int) (*BrowserSession, error) {
|
||
if !ps.VNCEnabled() {
|
||
return nil, fmt.Errorf("server-side browser login is disabled")
|
||
}
|
||
if ps.find(plugin) == nil {
|
||
return nil, fmt.Errorf("plugin %s not found", plugin)
|
||
}
|
||
if startURL == "" {
|
||
return nil, fmt.Errorf("the browser flow needs a start URL")
|
||
}
|
||
if callbackPort == 0 {
|
||
// Pick a free loopback port for the OAuth redirect to land on.
|
||
p, err := freeLoopbackPort()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
callbackPort = p
|
||
}
|
||
|
||
display := fmt.Sprintf(":%d", 90+len(ps.browsers().sessions)%10)
|
||
vncPort, err := freeLoopbackPort()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
wsPort, err := freeLoopbackPort()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
profDir, err := os.MkdirTemp("", "llmsproxy-browser-*")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.Chmod(profDir, 0o700); err != nil {
|
||
_ = os.RemoveAll(profDir)
|
||
return nil, err
|
||
}
|
||
|
||
sess := &browserSession{
|
||
info: BrowserSession{
|
||
ID: newLoginID(), // same unguessable-id generator
|
||
Plugin: plugin,
|
||
Platform: platform,
|
||
VNCPort: vncPort,
|
||
WSPort: wsPort,
|
||
Display: display,
|
||
CallbackPort: callbackPort,
|
||
StartedAt: time.Now(),
|
||
ExpiresAt: time.Now().Add(browserSessionTTL),
|
||
},
|
||
procs: browserProcs{profDir: profDir},
|
||
done: make(chan struct{}),
|
||
}
|
||
|
||
if err := ps.launchBrowserStack(sess, startURL, callbackPort, wsPort, vncPort, display, profDir); err != nil {
|
||
sess.teardown()
|
||
return nil, err
|
||
}
|
||
|
||
ps.browsers().mu.Lock()
|
||
ps.browsers().sessions[sess.info.ID] = sess
|
||
ps.browsers().mu.Unlock()
|
||
|
||
// The viewer is mounted under the plugin's own namespace so it inherits the
|
||
// gateway's authentication. Exposing VNC directly would hand anyone who can
|
||
// reach the port a logged-in browser.
|
||
// A relative query so noVNC's bundled JS connects its WebSocket back through
|
||
// THIS path (the gateway proxy) rather than guessing a host:port.
|
||
sess.info.ViewURL = fmt.Sprintf("/browser/%s/vnc.html?autoconnect=1&path=browser/%s/websockify",
|
||
sess.info.ID, sess.info.ID)
|
||
out := sess.info
|
||
return &out, nil
|
||
}
|
||
|
||
// launchBrowserStack starts the five processes and wires the loopback capture.
|
||
func (ps *Plugins) launchBrowserStack(sess *browserSession, startURL string, callbackPort, wsPort, vncPort int, display, profDir string) error {
|
||
// 1. Virtual display.
|
||
sess.procs.xvfb = exec.Command("Xvfb", display, "-screen", "0", "1280x800x24", "-nolisten", "tcp")
|
||
sess.procs.xvfb.Stdout, sess.procs.xvfb.Stderr = nil, nil
|
||
if err := sess.procs.xvfb.Start(); err != nil {
|
||
return fmt.Errorf("start Xvfb (is it installed?): %w", err)
|
||
}
|
||
// Xvfb needs a moment before the display exists.
|
||
if err := waitForDisplay(display, 5*time.Second); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 2. Loopback callback listener — the reason this whole thing exists.
|
||
cb := make(chan loopbackCallback, 1)
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query()
|
||
cb <- loopbackCallback{code: q.Get("code"), state: q.Get("state"), err: q.Get("error")}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
fmt.Fprint(w, "<h2>Login received. You can close this tab.</h2>")
|
||
})
|
||
srv := &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", callbackPort), Handler: mux}
|
||
ln, err := net.Listen("tcp", srv.Addr)
|
||
if err != nil {
|
||
return fmt.Errorf("bind loopback callback port %d: %w", callbackPort, err)
|
||
}
|
||
sess.procs.cbSrv = srv
|
||
go func() { _ = srv.Serve(ln) }()
|
||
|
||
// Capture the callback into the session record.
|
||
go func() {
|
||
select {
|
||
case c := <-cb:
|
||
sess.mu.Lock()
|
||
sess.info.CapturedCode, sess.info.CapturedState, sess.info.Error = c.code, c.state, c.err
|
||
sess.mu.Unlock()
|
||
case <-sess.done:
|
||
}
|
||
}()
|
||
|
||
// 3. Real browser on the virtual display.
|
||
args := []string{
|
||
"--no-sandbox", // the container has no usable sandbox; see the security note
|
||
"--disable-dev-shm-usage",
|
||
"--user-data-dir=" + profDir,
|
||
"--no-first-run",
|
||
"--no-default-browser-check",
|
||
"--window-size=1280,800",
|
||
startURL,
|
||
}
|
||
chromium, err := findChromium()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
sess.procs.chromium = exec.Command(chromium, args...)
|
||
sess.procs.chromium.Env = append(os.Environ(), "DISPLAY="+display)
|
||
if err := sess.procs.chromium.Start(); err != nil {
|
||
return fmt.Errorf("start chromium: %w", err)
|
||
}
|
||
|
||
// 4. VNC server on the same display.
|
||
sess.procs.x11vnc = exec.Command("x11vnc", "-display", display,
|
||
"-rfbport", fmt.Sprint(vncPort), "-localhost", "-nopw", "-forever", "-shared", "-quiet")
|
||
if err := sess.procs.x11vnc.Start(); err != nil {
|
||
return fmt.Errorf("start x11vnc: %w", err)
|
||
}
|
||
|
||
// 5. websockify bridges the browser's WebSocket to raw VNC for noVNC.
|
||
sess.procs.ws = exec.Command("websockify", "--web", "/usr/share/novnc",
|
||
fmt.Sprint(wsPort), fmt.Sprintf("127.0.0.1:%d", vncPort))
|
||
if err := sess.procs.ws.Start(); err != nil {
|
||
return fmt.Errorf("start websockify: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// teardown kills every process and removes the profile (which holds the session
|
||
// cookies for the logged-in account).
|
||
func (s *browserSession) teardown() {
|
||
func() {
|
||
defer func() { _ = recover() }()
|
||
close(s.done)
|
||
}()
|
||
if s.procs.cbSrv != nil {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||
_ = s.procs.cbSrv.Shutdown(ctx)
|
||
cancel()
|
||
}
|
||
for _, c := range []*exec.Cmd{s.procs.ws, s.procs.x11vnc, s.procs.chromium, s.procs.xvfb} {
|
||
if c == nil || c.Process == nil {
|
||
continue
|
||
}
|
||
_ = c.Process.Kill()
|
||
_, _ = c.Process.Wait()
|
||
}
|
||
if s.procs.profDir != "" {
|
||
// The profile holds live third-party session cookies: removing it is
|
||
// part of the security contract, not housekeeping.
|
||
_ = os.RemoveAll(s.procs.profDir)
|
||
}
|
||
}
|
||
|
||
// GetBrowserSession returns a copy for the UI.
|
||
func (ps *Plugins) GetBrowserSession(id string) (*BrowserSession, bool) {
|
||
bs := ps.browsers()
|
||
bs.mu.Lock()
|
||
s, ok := bs.sessions[id]
|
||
bs.mu.Unlock()
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
s.mu.Lock()
|
||
cp := s.info
|
||
s.mu.Unlock()
|
||
// Expiry applied on read, same as login sessions.
|
||
if time.Now().After(cp.ExpiresAt) {
|
||
ps.StopBrowserSession(id)
|
||
cp.Error = "the browser session timed out"
|
||
}
|
||
return &cp, true
|
||
}
|
||
|
||
// StopBrowserSession tears one session down and forgets it.
|
||
func (ps *Plugins) StopBrowserSession(id string) {
|
||
bs := ps.browsers()
|
||
bs.mu.Lock()
|
||
s, ok := bs.sessions[id]
|
||
if ok {
|
||
delete(bs.sessions, id)
|
||
}
|
||
bs.mu.Unlock()
|
||
if ok {
|
||
s.teardown()
|
||
}
|
||
}
|
||
|
||
// StopBrowserSessionsFor tears down every session a plugin owns (unload path).
|
||
func (ps *Plugins) StopBrowserSessionsFor(plugin string) int {
|
||
bs := ps.browsers()
|
||
bs.mu.Lock()
|
||
var victims []*browserSession
|
||
for id, s := range bs.sessions {
|
||
if s.info.Plugin == plugin {
|
||
victims = append(victims, s)
|
||
delete(bs.sessions, id)
|
||
}
|
||
}
|
||
bs.mu.Unlock()
|
||
for _, s := range victims {
|
||
s.teardown()
|
||
}
|
||
return len(victims)
|
||
}
|
||
|
||
// ---- helpers ----
|
||
|
||
func findChromium() (string, error) {
|
||
for _, name := range []string{"chromium", "chromium-browser", "google-chrome"} {
|
||
if p, err := exec.LookPath(name); err == nil {
|
||
return p, nil
|
||
}
|
||
}
|
||
return "", fmt.Errorf("no chromium found on PATH; the server-side browser flow needs one")
|
||
}
|
||
|
||
func freeLoopbackPort() (int, error) {
|
||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer l.Close()
|
||
return l.Addr().(*net.TCPAddr).Port, nil
|
||
}
|
||
|
||
func waitForDisplay(display string, timeout time.Duration) error {
|
||
// Xvfb does not signal readiness; probing the unix socket is the reliable way.
|
||
sock := filepath.Join("/tmp/.X11-unix", "X"+strings.TrimPrefix(display, ":"))
|
||
deadline := time.Now().Add(timeout)
|
||
for time.Now().Before(deadline) {
|
||
if _, err := os.Stat(sock); err == nil {
|
||
return nil
|
||
}
|
||
time.Sleep(50 * time.Millisecond)
|
||
}
|
||
return fmt.Errorf("display %s did not come up within %s", display, timeout)
|
||
}
|