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:
JianFeeeee
2026-10-11 14:09:09 +08:00
parent c3b858d5ba
commit fa50ec8bc7
9 changed files with 1448 additions and 0 deletions

View File

@ -0,0 +1,388 @@
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)
}