mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
refactor(lua): 内核回归六项职责 —— 登录/浏览器全部移入插件
用户钉死了内核的职责边界,本提交把越界部分全部拆除:
内核只做:加载适配器 · auto 调度 · 加载插件 · 调度插件 · 维护源与 key · 签发密钥
## 架构纠正(用户指出的根本错误)
我此前把信任模型弄反了。实测:插件 VM 的 os.execute/io.popen/package 全部可达
(true,true,true) —— 插件从来不是 OS 级沙箱。正确的边界是:
插件 可信(运维安装),非沙箱,有完整 OS 能力 → 平台知识/登录/浏览器归它
适配器 不可信(可被动态安装),请求级 → 沙箱归它(12 个内置适配器实测均不碰
os/io/package,后续沙箱化零风险,属独立一步)
## 删除(内核越界部分)
- plugin_browser.go 内核浏览器管理器(Xvfb/chromium/x11vnc/websockify
进程栈、回环监听器)—— 388 行全删,浏览器由插件用
os.execute 自己拉起(实测 spawn+reap 闭环可用)
- plugin_login.go 登录会话状态机 + LoginFlow 枚举 + VNC 全局门禁
—— 457 行全删。"登录"从内核概念中消失
- gateway/browser_proxy.go viewer 反代
- handlePluginLogin API 登录会话列表端点
- vnc_enabled 全局开关 改为 per-plugin 授权(见下)
## 新增(内核职责内的通用机制,无任何登录/浏览器语义)
- plugin_outbound.go 通用额外出站主机授权:插件声明 plugin.hosts(请求)
→ 运维在设置页逐个批准(授权)。与 kernel actions
同一"声明≠授权"模型。内核只知道白名单加减,不知道
host 被用来干什么
- on_unload 生命周期钩子 卸载是"调度插件"的一部分:内核在关闭插件状态前调用
插件导出的 on_unload,让它自清理外部资源。内核不知道
资源是什么(进程/句柄/远端会话都行)。带进程回收回归
测试 TestOnUnloadReapsPluginProcesses
## channels.lua 全量接管
- 登录会话自持:plugin.state.sessions(nonce 只在 begin 响应出现一次,
惰性过期清理)
- 服务端浏览器栈自持:spawnBrowser 用 io.popen 拉起 Xvfb/chromium/x11vnc/
websockify 并记 pid,reapBrowser 回收;on_unload 兜底
- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化(轮询/nonce 回调)
→ ③ VNC 无头浏览器兜底(无浏览器系统/自动化无法完成的场景,如回环重定向
必须落在服务端)
- plugin.auth_hosts → plugin.hosts(语义泛化:出站主机授权与登录无关)
## 验收
- 全仓旧符号零残留(grep 权威确认,代码+注释+lua+测试)
- 内核非测试代码零厂商名
- build + vet 全绿;lua/core/gateway/types/scheduler/provider 全量测试通过
- channels 端到端(登录→凭据→add_source→模型可见)继续通过
This commit is contained in:
@ -15,7 +15,7 @@
|
||||
-- ------------------------
|
||||
-- 内核提供三样基础设施,插件自己不实现这些:
|
||||
--
|
||||
-- __llmsproxy_login 登录会话生命周期(create/poll/resolve)
|
||||
-- (登录会话不再由内核提供 —— 见下)
|
||||
-- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读)
|
||||
-- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…)
|
||||
--
|
||||
@ -31,8 +31,14 @@
|
||||
-- reset_health 上游恢复后清冷却
|
||||
-- status 面板显示运行状态
|
||||
--
|
||||
-- 登录主机也需批准(plugin.auth_hosts):登录页所在的域名不是上游源,
|
||||
-- 默认会被出站白名单拒绝——这是刻意的。
|
||||
-- 额外出站主机需批准(plugin.hosts):登录页所在的域名不是上游源,默认会被
|
||||
-- 出站白名单拒绝——这是刻意的,声明是请求、批准才是授权。
|
||||
--
|
||||
-- 登录会话归本插件自持(内核无"登录"概念):会话表在 plugin.state.sessions,
|
||||
-- 带 nonce 与过期时间;浏览器栈(Xvfb/chromium/x11vnc/websockify)由本插件用
|
||||
-- os.execute 直接拉起并记录 pid,插件卸载/会话结束时自己回收。
|
||||
-- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化流程(轮询/回调)
|
||||
-- → ③ VNC 无头浏览器兜底(无浏览器系统 / 自动化也无法完成的场景)。
|
||||
|
||||
local plugin = {
|
||||
name = "channels",
|
||||
@ -45,7 +51,7 @@ local plugin = {
|
||||
plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" }
|
||||
|
||||
-- 申请的登录主机(出站白名单)。内核里**不含**任何厂商名——这些只存在于插件。
|
||||
plugin.auth_hosts = { "auth.example.com", "accounts.google.com" }
|
||||
plugin.hosts = { "auth.example.com", "accounts.google.com" }
|
||||
|
||||
-- ============================================================================
|
||||
-- 平台表
|
||||
@ -147,56 +153,166 @@ end
|
||||
--
|
||||
-- 返回 {url=…, session=…} 给页面显示"请在自己的浏览器打开这个地址"。
|
||||
-- 登录完成后用户浏览器会回到本插件(callback 流程)或本插件轮询到结果
|
||||
-- (link-poll 流程)。
|
||||
|
||||
-- ============================================================================
|
||||
-- 登录会话(插件自持)与服务端浏览器栈
|
||||
-- ============================================================================
|
||||
|
||||
-- 登录会话放 plugin.state.sessions:内核没有"登录"概念,这正是插件该自己干的
|
||||
-- 事。表结构 { id, platform, flow, url, user_code, nonce, state, account,
|
||||
-- created_at, expires_at }。nonce 只在 begin 响应里出现一次,存侧不外泄。
|
||||
local SESSION_TTL = 900 -- 15 分钟,过期会话在读取时惰性清理
|
||||
|
||||
local function newToken()
|
||||
-- 用 hmac 自己跟自己造一个不可猜的 id(内核给过 sha256_hex)
|
||||
return sha256_hex(tostring(os.time()) .. math.random(1e12) .. (plugin.name or "channels"))
|
||||
end
|
||||
|
||||
local function sessions()
|
||||
if not plugin.state.sessions then plugin.state.sessions = {} end
|
||||
return plugin.state.sessions
|
||||
end
|
||||
|
||||
-- sweepSessions 惰性清理过期/终态会话(无后台定时器,读取时顺带做)
|
||||
local function sweepSessions()
|
||||
local now = os.time and os.time() or 0
|
||||
for id, s in pairs(sessions()) do
|
||||
if s.expires_at and s.expires_at < now then
|
||||
sessions()[id] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- 服务端浏览器栈(VNC 兜底用)----
|
||||
-- 每个会话一组进程:Xvfb + chromium + x11vnc + websockify。pid 记在
|
||||
-- plugin.state.browser.pids,插件卸载或会话结束时 kill。
|
||||
-- 绑定地址默认 127.0.0.1(同机操作);远程运维可把 settings.vnc_bind 设成
|
||||
-- "0.0.0.0" 并依赖 x11vnc 随机会话密码(不设密码的裸 VNC 永远不会出现)。
|
||||
|
||||
local function browserStack()
|
||||
if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end
|
||||
return plugin.state.browser
|
||||
end
|
||||
|
||||
local function reapBrowser()
|
||||
local b = browserStack()
|
||||
for _, pid in ipairs(b.pids or {}) do
|
||||
os.execute("kill -9 " .. tostring(pid) .. " 2>/dev/null")
|
||||
end
|
||||
b.pids = {}
|
||||
b.session = nil
|
||||
end
|
||||
|
||||
-- spawnBrowser 拉起一组服务端浏览器。返回 view_url(给 iframe 内嵌)或 nil, err。
|
||||
-- start_url 是浏览器要打开的登录页;redirect_host 是拼 redirect_uri 用的主机名
|
||||
-- (浏览器在服务端,127.0.0.1 就是服务端自己的回环 —— 回调直接命中网关)。
|
||||
local function spawnBrowser(start_url, callback_path)
|
||||
local b = browserStack()
|
||||
if b.session then return nil, "已有浏览器会话在跑,请先停止" end
|
||||
b.seq = b.seq + 1
|
||||
local disp = 90 + (b.seq % 10)
|
||||
local vport = 59100 + (b.seq % 100)
|
||||
local wport = 60100 + (b.seq % 100)
|
||||
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)
|
||||
-- 端口占位换成网关回环(浏览器在服务端,回调直接命中网关挂载点)
|
||||
local url = start_url:gsub("redirect_uri=127%.0%.0%.1:%%d+", "redirect_uri=127.0.0.1:8081" .. callback_path)
|
||||
|
||||
local cmds = {
|
||||
("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp & echo $!"):format(disp),
|
||||
("chromium --no-sandbox --disable-dev-shm-usage --user-data-dir=/tmp/llmsproxy-ch-%d --no-first-run --window-size=1280,800 --display=:%d '%s' & echo $!"):format(b.seq, disp, url),
|
||||
("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet & echo $!"):format(disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass),
|
||||
("websockify --web /usr/share/novnc %s:%d 127.0.0.1:%d & echo $!"):format(bind, wport, vport),
|
||||
}
|
||||
local pids = {}
|
||||
for _, c in ipairs(cmds) do
|
||||
local h = io.popen(c)
|
||||
local pid = h:read("*l")
|
||||
h:close()
|
||||
if not pid or pid == "" then
|
||||
reapBrowser()
|
||||
return nil, "进程启动失败: " .. c:match("^(%S+)") .. "(缺依赖?运行 deploy 脚本一键安装)"
|
||||
end
|
||||
pids[#pids+1] = pid
|
||||
end
|
||||
-- 给 X/chromium 一点起进程时间;不等待就绪探针(保持插件逻辑简单,失败可重试)
|
||||
os.execute("sleep 2")
|
||||
b.pids = pids
|
||||
b.session = { display = disp, vnc = vport, ws = wport, pass = pass }
|
||||
-- view_url 指向 websockify 伺服的 noVNC;同机直接 127.0.0.1,远程用 settings.vnc_host 覆盖
|
||||
local host = (plugin.state.settings and plugin.state.settings.vnc_host) or "127.0.0.1"
|
||||
return {
|
||||
view_url = ("http://%s:%d/vnc.html?autoconnect=1&host=%s&port=%d&password=%s"):format(host, wport, host, wport, pass),
|
||||
password = pass,
|
||||
}
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- 登录流程:优先级阶梯(① 本机浏览器 → ② 自动化 → ③ VNC 兜底)
|
||||
-- ============================================================================
|
||||
|
||||
-- start_login 按优先级阶梯响应:
|
||||
-- ① 本机浏览器:直接返回登录 URL(绝大多数场景到此为止 —— 平台重定向打在
|
||||
-- 用户自己的机器上,插件轮询/收回调拿凭据)
|
||||
-- ② 自动化:link-poll/callback 流程本身就是自动的(轮询 + nonce 回调),
|
||||
-- 无需用户之外的动作
|
||||
-- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、
|
||||
-- 回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。
|
||||
function plugin.start_login(req)
|
||||
local args = bodyOf(req)
|
||||
local plat = platformById(args and args.platform)
|
||||
if not plat then
|
||||
return { status = 400, json = { error = "未知平台: " .. tostring(args and args.platform) } }
|
||||
end
|
||||
local s, err = __llmsproxy_login.begin({
|
||||
sweepSessions()
|
||||
local now = os.time and os.time() or 0
|
||||
local sess = {
|
||||
id = newToken():sub(1, 24),
|
||||
platform = plat.id,
|
||||
flow = plat.flow,
|
||||
url = plat.login_url,
|
||||
})
|
||||
if err then return { status = 400, json = { error = err } } end
|
||||
logEvent("开始登录 " .. plat.name)
|
||||
nonce = newToken():sub(1, 32),
|
||||
state = "pending",
|
||||
created_at = now,
|
||||
expires_at = now + SESSION_TTL,
|
||||
}
|
||||
sessions()[sess.id] = sess
|
||||
logEvent("开始登录 " .. plat.name .. " (" .. plat.flow .. ")")
|
||||
|
||||
-- 回环类平台:必须在**服务端**开浏览器。
|
||||
-- 理由:授权重定向打到 127.0.0.1,用户机器上的那个是**用户自己的**回环,
|
||||
-- 服务端收不到 code。只有浏览器跑在服务端,重定向才落到本进程的监听器上。
|
||||
local view_url
|
||||
-- 响应里只带一次 nonce(回调校验要用),存侧不回显
|
||||
local out = {
|
||||
session = { id = sess.id, platform = sess.platform, flow = sess.flow,
|
||||
state = sess.state, nonce = sess.nonce, expires_at = sess.expires_at },
|
||||
platform = plat.id,
|
||||
}
|
||||
if plat.flow == "vnc" then
|
||||
-- 先建一个回环监听会话(拿到 callback_port 才能拼出 redirect_uri)
|
||||
local bs, berr = __llmsproxy_login.start_browser({
|
||||
platform = plat.id,
|
||||
start_url = string.format(plat.login_url, 0), -- 端口占位,下面替换
|
||||
callback_port = 0,
|
||||
})
|
||||
-- ③ 兜底路径:服务端浏览器。重定向打在服务端回环上,回调命中本插件的挂载点。
|
||||
local bs, berr = spawnBrowser(string.format(plat.login_url, 0), "/api/plugins/channels/http/channels/callback")
|
||||
if berr then
|
||||
out.browser_error = berr
|
||||
out.hint = "服务端浏览器不可用;先装依赖(见插件一键初始化),或改用其他登录流程"
|
||||
logEvent("服务端浏览器启动失败: " .. berr)
|
||||
return {
|
||||
status = 200,
|
||||
json = {
|
||||
url = s.url, session = s, platform = plat.id,
|
||||
browser_error = berr,
|
||||
hint = "服务端浏览器未启用或不可用;可在设置页开启 VNC 流程(默认关闭)",
|
||||
},
|
||||
}
|
||||
else
|
||||
out.view_url = bs.view_url
|
||||
logEvent("服务端浏览器已就绪 " .. plat.id)
|
||||
end
|
||||
else
|
||||
-- ①②:URL 交给用户自己的浏览器;callback 流程的 redirect_uri 指向本插件挂载点
|
||||
if plat.flow == "callback" then
|
||||
out.url = plat.login_url .. "&redirect_uri=" .. "127.0.0.1:8081/api/plugins/channels/http/channels/callback&state=" .. sess.nonce
|
||||
else
|
||||
out.url = plat.login_url
|
||||
end
|
||||
view_url = bs.view_url
|
||||
logEvent("服务端浏览器已就绪 " .. plat.id .. " 回调端口=" .. tostring(bs.callback_port))
|
||||
end
|
||||
|
||||
return { status = 200, json = {
|
||||
url = s.url, session = s, platform = plat.id, view_url = view_url,
|
||||
} }
|
||||
return { status = 200, json = out }
|
||||
end
|
||||
|
||||
-- plugin.poll_login(session_id) 由页面轮询:会话是否完成。
|
||||
function plugin.poll_login(req)
|
||||
local id = (req and req.query and req.query.id) or ""
|
||||
local s = __llmsproxy_login.get(id)
|
||||
sweepSessions()
|
||||
local s = sessions()[id]
|
||||
if not s then return { status = 200, json = { state = "gone" } } end
|
||||
return { status = 200, json = { state = s.state, account = s.account, message = s.message } }
|
||||
end
|
||||
@ -224,6 +340,10 @@ function plugin.complete_login(req)
|
||||
added_at = os.time and os.time() or 0,
|
||||
enabled = true,
|
||||
}
|
||||
-- 终结该平台未决会话
|
||||
for id, sess in pairs(sessions()) do
|
||||
if sess.platform == plat.id and sess.state == "pending" then sess.state = "completed" end
|
||||
end
|
||||
logEvent("已登录 " .. plat.name)
|
||||
|
||||
-- 接入为上游源 —— 这是本插件存在的意义
|
||||
@ -361,7 +481,11 @@ function plugin.oauth_callback(req)
|
||||
if not nonce or nonce == "" then
|
||||
return { status = 400, body = "missing state" }
|
||||
end
|
||||
local s = __llmsproxy_login.get(nonce)
|
||||
sweepSessions()
|
||||
local s = nil
|
||||
for _, sess in pairs(sessions()) do
|
||||
if sess.nonce == nonce then s = sess break end
|
||||
end
|
||||
if not s then
|
||||
return { status = 400, body = "unknown or expired login session" }
|
||||
end
|
||||
@ -376,10 +500,17 @@ function plugin.oauth_callback(req)
|
||||
if res.error then
|
||||
return { status = 400, body = res.error }
|
||||
end
|
||||
__llmsproxy_login.resolve(s.id, "completed", "oauth", "")
|
||||
s.state = "completed"
|
||||
s.account = "oauth"
|
||||
return { status = 200, body = "ok" }
|
||||
end
|
||||
|
||||
-- plugin.on_unload:插件被卸载/重载时回收自己拉起的浏览器栈。进程是插件起的,
|
||||
-- 收尾也是插件的责任 —— 内核不应知道这些进程的存在。
|
||||
plugin.on_unload = function()
|
||||
reapBrowser()
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- UI:独立标签页(登录 / 认证 / 配置)
|
||||
-- ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user