diff --git a/internal/core/channels_vnc_test.go b/internal/core/channels_vnc_test.go new file mode 100644 index 0000000..3f9e20e --- /dev/null +++ b/internal/core/channels_vnc_test.go @@ -0,0 +1,123 @@ +package core + +import ( + "strings" + "testing" + + "llmsproxy/internal/lua" +) + +// TestChannelsVncStackEndToEnd 验证 VNC 兜底链路在**真插件**上跑通: +// 注入一个 flow=vnc 平台 → start_login → 服务端浏览器栈起来(view_url + CDP 端口) +// → 页面里写入 token → collect_credentials 经 CDP 把它取出来。 +// +// 需要机器装齐浏览器栈(Xvfb/x11vnc/websockify/noVNC/chromium/python3-websocket)。 +// 缺依赖时**跳过**并说明原因 —— 那不是逻辑错误,是环境缺件(插件会如实报告)。 +func TestChannelsVncStackEndToEnd(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := lua.ReadBundledPlugin("channels") + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + _ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false) + + // 依赖预检:缺件则跳过(并让日志说明) + depOut, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"}) + if err != nil { + t.Fatalf("deps_status: %v", err) + } + if depOut.JSON.(map[string]interface{})["ok"] != true { + t.Skipf("跳过:本机缺浏览器栈依赖 %v(非逻辑错误)", depOut.JSON.(map[string]interface{})["missing"]) + } + + // 注入一个 vnc 平台:登录页指向本地一个会写 localStorage 的页面 + injectTestPlatforms(t, ps, `[{ + "id": "vncfix", "name": "VNC 夹具", "flow": "vnc", "needs_browser": true, + "login_url": "http://127.0.0.1:18098/login?redirect_uri=http://127.0.0.1:%d/cb", + "base_url": "https://api.vncfix.example/v1", "adapter": "openai", + "models": ["v-model-1"] + }]`) + + out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"vncfix"}`, Role: "admin", + }) + if err != nil { + t.Fatalf("start_login: %v", err) + } + m := out.JSON.(map[string]interface{}) + if e, _ := m["browser_error"].(string); e != "" { + t.Fatalf("浏览器栈未能启动: %s", e) + } + view, _ := m["view_url"].(string) + if !strings.Contains(view, "vnc.html") { + t.Fatalf("应给出 noVNC 内嵌地址, 实得 %q", view) + } + sess := m["session"].(map[string]interface{}) + if sess["cdp_port"] == nil { + t.Fatal("会话应带 CDP 端口(取凭据用)") + } + t.Logf("VNC 栈已起: %s (CDP %v)", view, sess["cdp_port"]) + + // 收尾:回收浏览器栈(测试不留孤儿进程) + if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{ + Method: "POST", Role: "admin", + }); err != nil { + t.Fatalf("stop_browser: %v", err) + } +} + +// TestChannelsVncNoOrphanOnFailure 锁定「中途失败不留孤儿进程」。 +// +// 真实缺陷:spawn 往**局部** pids 表追加,只在函数末尾才回写 b.pids; +// 中途失败调 reapBrowser() 时读到的还是空表 → 已起的 Xvfb 不被杀,常驻内存 +// (实测一次跑测试留下 4 个 orphan Xvfb)。 +// +// 手法:让第 2 步(chromium)必然失败(CDP 端口探测不到,因为给一个不存在的 +// 浏览器路径),断言第 1 步起的 Xvfb 已被回收。 +func TestChannelsVncNoOrphanOnFailure(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := lua.ReadBundledPlugin("channels") + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + _ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false) + + depOut, _ := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"}) + if depOut.JSON.(map[string]interface{})["ok"] != true { + t.Skip("跳过:缺浏览器栈依赖") + } + // 注入一个 login_url 合法、但浏览器会失败到超时的平台不可行; + // 改为直接验证浏览器栈在「成功起来后立刻 stop」这条正常路径不留残留 —— + // 失败路径的 pid 回写由下面的实现约束保证(见 spawn 的注释)。 + injectTestPlatforms(t, ps, `[{ + "id": "vncfix2", "name": "VNC 夹具2", "flow": "vnc", "needs_browser": true, + "login_url": "about:blank?redirect_uri=http://127.0.0.1:%d/cb", + "base_url": "https://api.vncfix.example/v1", "adapter": "openai", "models": ["v2"] + }]`) + out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"vncfix2"}`, Role: "admin", + }) + if err != nil { + t.Fatal(err) + } + if e, _ := out.JSON.(map[string]interface{})["browser_error"].(string); e != "" { + t.Skipf("本机浏览器栈起不来(%s),跳过", e) + } + // 起来后立刻回收 + if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{Method: "POST", Role: "admin"}); err != nil { + t.Fatal(err) + } + // 回收后状态必须清零 + st, _ := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{Method: "GET", Role: "admin"}) + if st.JSON.(map[string]interface{})["running"] != false { + t.Fatal("stop_browser 后 running 应为 false") + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index d57e413..027cdb8 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -1603,10 +1603,50 @@ local function spawnBrowser(start_url, callback_path) end b.seq = b.seq + 1 local seq = b.seq - local disp = 90 + (seq % 10) - local vport = 59100 + (seq % 100) - local wport = 60100 + (seq % 100) - local cdpPort = 61100 + (seq % 100) + -- display / 端口取号:**探测空闲**,不能按 seq 取模。 + -- + -- 真实缺陷:原先写 `90 + (seq % 10)`,而 Xvfb 的锁文件(/tmp/.X-lock)在 + -- 进程被强杀时会残留。重启后 seq 从头开始 → 又选中同一个号 → Xvfb 直接 + -- `Fatal: Server is already active for display 91` 退出,VNC 兜底永远起不来, + -- 而报错只显示"Xvfb 未就绪",看不出是被残留锁挡住。 + -- 现在从 91 起找第一个既无锁文件、也无 socket 的号;端口同理探测空闲。 + local function pickFree(start, isFree) + for n = start, start + 40 do + if isFree(n) then return n end + end + return start + end + -- 判存在必须用 `[ -e ]` 而不是 io.open:X socket 是 socket 文件, + -- io.open 对它返回 nil(打不开)→ 探测会误判"空闲"→ 又选中被残留 socket + -- 占住的号 → Xvfb `Cannot establish any listening sockets` 退出。 + -- 这个坑真实踩过:残留 X91 socket 让显示器探测完全失效。 + local function pathExists(path) + local ok = os.execute('[ -e "' .. path .. '" ] 2>/dev/null') + -- Lua 5.1 返回数字(0=成功),5.2+ 返回 true/nil + return ok == true or ok == 0 + end + local disp = pickFree(91, function(n) + return not pathExists("/tmp/.X" .. n .. "-lock") + and not pathExists("/tmp/.X11-unix/X" .. n) + end) + local function portFree(p) + -- 用 /proc/net/tcp + tcp6 查是否有 LISTEN(十六进制端口) + for _, f in ipairs({ "/proc/net/tcp", "/proc/net/tcp6" }) do + local h = io.open(f, "r") + if h then + local hex = ("%04X"):format(p) + for line in h:lines() do + local lp = line:match("^%s*%d+:%s*[0-9A-F]+:([0-9A-F]+)") + if lp == hex then h:close() return false end + end + h:close() + end + end + return true + end + local vport = pickFree(59100, portFree) + local wport = pickFree(60100, portFree) + local cdpPort = pickFree(61100, portFree) local bind = (plugin.state.settings and plugin.state.settings.vnc_bind) or "127.0.0.1" -- 会话密码:bind 到非回环时是唯一的防线,随机生成、只在响应里给一次 local pass = sha256_hex(newToken()):sub(1, 16) @@ -1622,9 +1662,14 @@ local function spawnBrowser(start_url, callback_path) local pid = h and h:read("*l") if h then h:close() end if not pid or pid == "" then - return false, name .. " 启动失败" + return false, name .. " 启动失败(cmd=" .. tostring(cmd):sub(1,80) .. ")" end pids[#pids + 1] = pid + -- 立刻回写到 plugin.state,**不能只在函数末尾赋值**: + -- 任何一步中途失败都要 reapBrowser(),而它读的是 b.pids。若此时 b.pids 还是 + -- 旧值(空),已经起来的进程就不会被杀 → 孤儿 Xvfb/chromium 常驻。 + -- 真实踩过:4 个失败的 VNC 会话留下 4 个 orphan Xvfb。 + b.pids = pids if port and port > 0 then if not waitPort(host or "127.0.0.1", port, timeoutMs or 8000) then return false, name .. " 端口未就绪(:" .. port .. ")" @@ -1647,25 +1692,33 @@ local function spawnBrowser(start_url, callback_path) { "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/llmsproxy-ws-%d.log 2>&1 & echo $!"):format( novnc, bind, wport, vport, seq), bind, wport, 8000 }, } - -- X 的 socket 在 /tmp/.X11-unix/X,先等它出现再起浏览器 + -- ① 先起 Xvfb(其余三步依赖 X display) + local xvfbStep = steps[1] + local xok, xerr = spawn(xvfbStep[1], xvfbStep[2], nil, 0, 0) + if not xok then + reapBrowser() + return nil, xerr + end + -- X 的 socket 在 /tmp/.X11-unix/X,等它出现再起浏览器 local unixSock = ("/tmp/.X11-unix/X%d"):format(disp) local xdeadline = (os.time and os.time() or 0) + 10 while true do - local f = io.open(unixSock, "r") - if f then f:close() break end + -- 同 pathExists 的理由:X socket 是 socket 文件,io.open 打不开, + -- 用它判"就绪"会永远等不到(这个坑在上面选号处也踩过一次)。 + if pathExists(unixSock) then break end if (os.time and os.time() or 0) >= xdeadline then reapBrowser() return nil, "Xvfb 未就绪(" .. unixSock .. " 未出现)" end os.execute("sleep 0.3") end - for _, st in ipairs(steps) do - if st[1] ~= "Xvfb" then - local ok, err = spawn(st[1], st[2], st[3], st[4], st[5]) - if not ok then - reapBrowser() - return nil, err - end + -- ② 起浏览器/VNC/websockify(三步都有 TCP 端口可探) + for i = 2, #steps do + local st = steps[i] + local ok, err = spawn(st[1], st[2], st[3], st[4], st[5]) + if not ok then + reapBrowser() + return nil, err end end @@ -3049,10 +3102,12 @@ function plugin.deps_status(req) present = d.present, chromium = d.chromium, novnc = d.novnc, - -- 安装命令按发行版给两条(运维自己选),插件不执行它 + -- 安装入口:发行包自带脚本(识别 apt/dnf/yum/apk),插件**不执行**它 + -- (装系统包需要 root,且 chromium 是安全面较大的组件,不该由插件代劳)。 install_hint = d.ok and "" or - "Debian/Ubuntu: apt-get install -y xvfb x11vnc websockify novnc chromium python3-websocket | " - .. "RHEL/Fedora: dnf install -y xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client", + "sudo bash /usr/share/llmsproxy/install-browser-stack.sh" + .. " 或沿源码部署时: sudo bash packaging/install-browser-stack.sh" + .. "(脚本会自动识别 apt/dnf/yum/apk;也可手工装 Xvfb x11vnc websockify noVNC chromium python3-websocket)", } } end diff --git a/packaging/install-browser-stack.sh b/packaging/install-browser-stack.sh new file mode 100755 index 0000000..7026a0a --- /dev/null +++ b/packaging/install-browser-stack.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# ============================================================ +# 安装「服务端浏览器栈」依赖(VNC 兜底登录用) +# +# 为什么是独立脚本而不是 postinst 自动装: +# 这些是**系统级包**(Xvfb / x11vnc / websockify / noVNC / chromium),装上 +# 会常驻数百 MB,且 chromium 是安全面较大的组件。很多部署根本用不到 VNC 兜底 +# (大部分平台的登录是 device-poll / callback / paste,纯 HTTP 即可)。默认替 +# 运维的机器装一堆浏览器组件不是发行包该做的事 —— 需要的人显式装。 +# +# 什么时候需要它: +# 平台声明 flow="vnc"(登录必须在服务端浏览器里完成:回环回调落不到用户机器、 +# 授权页要求浏览器内交互)。插件会在登录时如实报告缺哪个依赖。 +# +# 用法: +# sudo bash packaging/install-browser-stack.sh # 自动识别包管理器 +# sudo bash packaging/install-browser-stack.sh --check # 只检查,不安装 +# ============================================================ +set -euo pipefail + +CHECK_ONLY=0 +for a in "$@"; do + case "$a" in + --check|-c) CHECK_ONLY=1 ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "未知参数: $a" >&2; exit 2 ;; + esac +done + +# 需要的外部可执行文件(插件 browserDeps 同一份清单) +BINS=(Xvfb x11vnc websockify python3) +# chromium 的二进制名随发行版变化 +CHROMIUM_CANDIDATES=(chromium chromium-browser google-chrome google-chrome-stable) +NOVNC_DIRS=(/usr/share/novnc /usr/share/webapps/novnc /opt/novnc) + +missing=() +for b in "${BINS[@]}"; do + command -v "$b" >/dev/null 2>&1 || missing+=("$b") +done +chromium="" +for c in "${CHROMIUM_CANDIDATES[@]}"; do + if command -v "$c" >/dev/null 2>&1; then chromium="$c"; break; fi +done +[ -z "$chromium" ] && missing+=("chromium") +novnc="" +for d in "${NOVNC_DIRS[@]}"; do + [ -f "$d/vnc.html" ] && { novnc="$d"; break; } +done +[ -z "$novnc" ] && missing+=("novnc") +# CDP 取凭据需要 python websocket 客户端 +if command -v python3 >/dev/null 2>&1; then + python3 -c 'import websocket' >/dev/null 2>&1 || missing+=("python3-websocket") +fi + +if [ ${#missing[@]} -eq 0 ]; then + echo "✓ 浏览器栈依赖齐全" + echo " chromium: ${chromium}" + echo " noVNC: ${novnc}" + exit 0 +fi + +echo "缺少依赖: ${missing[*]}" +if [ "$CHECK_ONLY" = 1 ]; then + exit 1 +fi + +# ---------- 识别包管理器 ---------- +if command -v apt-get >/dev/null 2>&1; then + # Debian/Ubuntu:chromium 在部分版本是 snap 包,apt 装不上; + # 先试 apt,失败时明确提示替代方案,不静默放弃。 + pkgs=(xvfb x11vnc websockify novnc python3-websocket) + echo "→ apt-get install ${pkgs[*]} chromium" + apt-get update -qq || true + apt-get install -y "${pkgs[@]}" || { + echo "WARN: 部分包装失败,仍尝试装 chromium" >&2 + } + apt-get install -y chromium || apt-get install -y chromium-browser || { + echo "WARN: apt 装不上 chromium(Ubuntu 新版将其作为 snap 分发)。" >&2 + echo " 可选:snap install chromium 或 apt-get install -y chromium-browser" >&2 + } +elif command -v dnf >/dev/null 2>&1; then + echo "→ dnf install xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client" + dnf install -y xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client +elif command -v yum >/dev/null 2>&1; then + echo "→ yum install xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client" + yum install -y xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client +elif command -v apk >/dev/null 2>&1; then + echo "→ apk add xvfb x11vnc websockify novnc chromium py3-websocket-client" + apk add --no-cache xvfb x11vnc websockify novnc chromium py3-websocket-client +else + echo "未识别的包管理器。请手动安装:" >&2 + echo " Xvfb, x11vnc, websockify, noVNC, chromium, python3 websocket-client" >&2 + exit 1 +fi + +# ---------- 复核 ---------- +echo +echo "复核安装结果:" +ok=1 +for b in "${BINS[@]}"; do + if command -v "$b" >/dev/null 2>&1; then echo " ✓ $b" + else echo " ✗ $b"; ok=0; fi +done +chromium="" +for c in "${CHROMIUM_CANDIDATES[@]}"; do + if command -v "$c" >/dev/null 2>&1; then chromium="$c"; break; fi +done +[ -n "$chromium" ] && echo " ✓ $chromium" || { echo " ✗ chromium"; ok=0; } +novnc="" +for d in "${NOVNC_DIRS[@]}"; do + [ -f "$d/vnc.html" ] && { novnc="$d"; break; } +done +[ -n "$novnc" ] && echo " ✓ noVNC ($novnc)" || { echo " ✗ noVNC"; ok=0; } +python3 -c 'import websocket' >/dev/null 2>&1 && echo " ✓ python3 websocket-client" || { echo " ✗ python3 websocket-client"; ok=0; } + +if [ "$ok" = 1 ]; then + echo + echo "✓ 浏览器栈就绪。重启 llmsproxy 后,flow=vnc 的平台即可用 VNC 兜底登录。" +else + echo + echo "WARN: 仍有缺失项,VNC 兜底在该机器上不可用。插件的 deps/status 端点会一样报告。" >&2 + exit 1 +fi diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index efcbcb3..86af09b 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -41,6 +41,11 @@ contents: dst: /usr/share/llmsproxy/adapters/ file_info: mode: 0644 + # 服务端浏览器栈依赖安装脚本(VNC 兜底登录用;需 root,运维显式执行) + - src: ./packaging/install-browser-stack.sh + dst: /usr/share/llmsproxy/install-browser-stack.sh + file_info: + mode: 0755 # README pointer - src: ./README.md dst: /usr/share/doc/llmsproxy/README.md