mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户钉死了内核的职责边界,本提交把越界部分全部拆除:
内核只做:加载适配器 · 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→模型可见)继续通过
618 lines
26 KiB
Lua
618 lines
26 KiB
Lua
-- channels:多平台账号接入(登录认证 → 凭据 → 自动领取额度 → 注册为上游源)
|
||
--
|
||
-- 为什么需要它
|
||
-- ------------
|
||
-- 网关的上游源(sources)一直是**运维手工配置**的:填 base_url、填 api_key。
|
||
-- 而现实里有大量平台是"注册个账号、每天领免费额度"的形态,它们的接入方式是
|
||
-- **登录**而不是"申请一个 key"。这类平台要接入网关,就需要:驱动登录 → 拿到
|
||
-- 凭据 → 定时领取额度 → 把端点当源用。
|
||
--
|
||
-- 本插件把这套流程做成通用的:一个插件内建平台表,每个平台声明自己的
|
||
-- 登录方式(link-poll / callback / paste)、额度领取接口、上游端点形状。
|
||
-- 新增一个平台是往 PLATFORMS 里加一条,不改内核。
|
||
--
|
||
-- 设计边界(与内核的约定)
|
||
-- ------------------------
|
||
-- 内核提供三样基础设施,插件自己不实现这些:
|
||
--
|
||
-- (登录会话不再由内核提供 —— 见下)
|
||
-- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读)
|
||
-- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…)
|
||
--
|
||
-- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状。
|
||
-- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"——
|
||
-- 内核里出现厂商名就是分层被破坏了。
|
||
--
|
||
-- 权限(本插件需要,全部可在设置页看到"申请中")
|
||
-- ------------------------------------------------
|
||
-- add_source 把平台端点注册为上游源
|
||
-- list_sources 显示当前已接入的平台
|
||
-- probe_all 接入后立刻验证可达
|
||
-- reset_health 上游恢复后清冷却
|
||
-- status 面板显示运行状态
|
||
--
|
||
-- 额外出站主机需批准(plugin.hosts):登录页所在的域名不是上游源,默认会被
|
||
-- 出站白名单拒绝——这是刻意的,声明是请求、批准才是授权。
|
||
--
|
||
-- 登录会话归本插件自持(内核无"登录"概念):会话表在 plugin.state.sessions,
|
||
-- 带 nonce 与过期时间;浏览器栈(Xvfb/chromium/x11vnc/websockify)由本插件用
|
||
-- os.execute 直接拉起并记录 pid,插件卸载/会话结束时自己回收。
|
||
-- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化流程(轮询/回调)
|
||
-- → ③ VNC 无头浏览器兜底(无浏览器系统 / 自动化也无法完成的场景)。
|
||
|
||
local plugin = {
|
||
name = "channels",
|
||
version = "1.0.0",
|
||
description = "多平台账号接入:登录认证、凭据保管、自动领取额度、注册为上游源",
|
||
author = "llmsproxy",
|
||
}
|
||
|
||
-- 申请的内核权限。设置页会把"申请中但未授权"标红。
|
||
plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" }
|
||
|
||
-- 申请的登录主机(出站白名单)。内核里**不含**任何厂商名——这些只存在于插件。
|
||
plugin.hosts = { "auth.example.com", "accounts.google.com" }
|
||
|
||
-- ============================================================================
|
||
-- 平台表
|
||
-- ============================================================================
|
||
--
|
||
-- 每个平台声明四件事:
|
||
-- flow 登录方式:link-poll(给 URL 轮询)/ callback(OAuth 重定向)/
|
||
-- paste(用户粘贴 cookie)
|
||
-- login_url 用户要在**自己浏览器**打开的地址(link-poll/callback)
|
||
-- claim_path 额度领取接口(相对 providers 的 base)
|
||
-- source 接入网关时用的源定义模板({platform} 会被替换)
|
||
--
|
||
-- 新增平台 = 加一条。不需要动内核,也不需要动本文件的其它部分。
|
||
local PLATFORMS = {
|
||
{
|
||
id = "example",
|
||
name = "Example 平台",
|
||
flow = "link-poll",
|
||
login_url = "https://auth.example.com/device",
|
||
claim_path = "/api/daily/claim",
|
||
base_url = "https://api.example.com/v1",
|
||
models = { "example-chat", "example-reason" },
|
||
},
|
||
{
|
||
id = "googleish",
|
||
name = "Google 系",
|
||
flow = "callback",
|
||
login_url = "https://accounts.google.com/o/oauth2/auth",
|
||
claim_path = "/api/quota/claim",
|
||
base_url = "https://api.googleish.example/v1",
|
||
models = { "g-model-1" },
|
||
},
|
||
{
|
||
id = "loopbackish",
|
||
name = "回环登录平台",
|
||
-- 该类平台把授权重定向到 127.0.0.1。用户机器上的浏览器收不到(那是它自己的
|
||
-- 回环),所以必须在服务端开浏览器 —— 本插件会把它内嵌进这个页签。
|
||
flow = "vnc",
|
||
needs_browser = true,
|
||
login_url = "https://auth.loopbackish.example/authorize?redirect_uri=http://127.0.0.1:%d/callback",
|
||
claim_path = "/api/quota/claim",
|
||
base_url = "https://api.loopbackish.example/v1",
|
||
models = { "lb-model-1" },
|
||
},
|
||
{
|
||
id = "cookieish",
|
||
name = "Cookie 平台",
|
||
flow = "paste",
|
||
claim_path = "/api/credit/claim",
|
||
base_url = "https://api.cookieish.example/v1",
|
||
models = { "c-model-1" },
|
||
},
|
||
}
|
||
|
||
-- bodyOf 把 handler 的 req.body(**JSON 字符串**)解成 table。
|
||
--
|
||
-- 为什么需要它:handler 收到的是原始 body 字符串,不是解码后的表(内核不猜
|
||
-- 插件的载荷形状)。载荷坏掉时返回空表而不是报错 —— 调用方随后会因为缺少
|
||
-- 必要字段而得到明确的错误,比一个 JSON 解析异常更好读。
|
||
local function bodyOf(req)
|
||
if not req or not req.body or req.body == "" then return {} end
|
||
if type(req.body) == "table" then return req.body end
|
||
local ok, v = pcall(json.decode, req.body)
|
||
if not ok or type(v) ~= "table" then return {} end
|
||
return v
|
||
end
|
||
|
||
-- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表)
|
||
local function platformById(id)
|
||
for _, p in ipairs(PLATFORMS) do
|
||
if p.id == id then return p end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 状态
|
||
-- ============================================================================
|
||
|
||
plugin.state = plugin.state or {}
|
||
plugin.state.accounts = plugin.state.accounts or {}
|
||
plugin.state.log = plugin.state.log or {}
|
||
|
||
local function logEvent(msg)
|
||
table.insert(plugin.state.log, 1, { at = os.time and os.time() or 0, msg = msg })
|
||
while #plugin.state.log > 50 do table.remove(plugin.state.log) end
|
||
end
|
||
|
||
-- accountOf 取某平台的账号记录(凭据本身在保险库,不在 state 里)
|
||
local function accountOf(id)
|
||
return plugin.state.accounts[id]
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 登录
|
||
-- ============================================================================
|
||
|
||
-- plugin.start_login(platform_id) 由设置页调用:创建一个登录会话。
|
||
--
|
||
-- 返回 {url=…, session=…} 给页面显示"请在自己的浏览器打开这个地址"。
|
||
-- 登录完成后用户浏览器会回到本插件(callback 流程)或本插件轮询到结果
|
||
|
||
-- ============================================================================
|
||
-- 登录会话(插件自持)与服务端浏览器栈
|
||
-- ============================================================================
|
||
|
||
-- 登录会话放 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
|
||
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,
|
||
nonce = newToken():sub(1, 32),
|
||
state = "pending",
|
||
created_at = now,
|
||
expires_at = now + SESSION_TTL,
|
||
}
|
||
sessions()[sess.id] = sess
|
||
logEvent("开始登录 " .. plat.name .. " (" .. plat.flow .. ")")
|
||
|
||
-- 响应里只带一次 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
|
||
-- ③ 兜底路径:服务端浏览器。重定向打在服务端回环上,回调命中本插件的挂载点。
|
||
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)
|
||
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
|
||
end
|
||
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 ""
|
||
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
|
||
|
||
-- plugin.complete_login(args) 由页面/回调调用:登录成功,存凭据并接入源。
|
||
--
|
||
-- 凭据只进保险库(0600、跨插件不可读),不进 state:state 会被
|
||
-- PUT /api/plugins/channels/state 整体替换,也会出现在 WebUI 里。
|
||
function plugin.complete_login(req)
|
||
local args = bodyOf(req)
|
||
if args.platform == nil then
|
||
-- 内部调用会直接传表
|
||
args = req
|
||
end
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
local token = args and args.credential
|
||
if not token or token == "" then return { status = 400, json = { error = "缺少凭据" } } end
|
||
|
||
local ok, err = __llmsproxy_secrets.set("channels:" .. plat.id, token)
|
||
if err then return { status = 400, json = { error = err } } end
|
||
|
||
plugin.state.accounts[plat.id] = {
|
||
account = args.account or "已登录",
|
||
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)
|
||
|
||
-- 接入为上游源 —— 这是本插件存在的意义
|
||
local r, aerr = __llmsproxy_kernel.call("add_source", {
|
||
source = {
|
||
name = "channels-" .. plat.id,
|
||
base_url = plat.base_url,
|
||
adapter = "openai",
|
||
api_key = token,
|
||
max_concurrent = 2,
|
||
models = (function()
|
||
local ms = {}
|
||
for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end
|
||
return ms
|
||
end)(),
|
||
},
|
||
})
|
||
if aerr then
|
||
-- 凭据已存下,但接入源失败。如实分开报告:把两者混成一个 ok=false 会让
|
||
-- 调用方以为登录失败从而重复登录。
|
||
logEvent("接入源失败 " .. plat.id .. ": " .. aerr)
|
||
return { status = 200, json = { ok = true, source_error = aerr } }
|
||
end
|
||
logEvent("已接入源 " .. r.name)
|
||
return { status = 200, json = { ok = true, source = r.name } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 额度领取
|
||
-- ============================================================================
|
||
|
||
-- plugin.claim_daily(platform_id) 领每日额度。设计为**可被定时任务调用**。
|
||
--
|
||
-- 只返回结果,不改 state 之外的东西:真正的额度体现在平台的响应里
|
||
-- (已用/剩余),本插件记录以便面板显示。
|
||
function plugin.claim_daily(req)
|
||
local args = bodyOf(req)
|
||
if args.platform == nil then args = req end
|
||
local plat = platformById(args and args.platform)
|
||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||
local tok = __llmsproxy_secrets.get("channels:" .. plat.id)
|
||
if not tok then return { status = 400, json = { error = "未登录" } } end
|
||
|
||
local resp, err = __llmsproxy_http({
|
||
url = plat.base_url .. plat.claim_path,
|
||
method = "POST",
|
||
headers = { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" },
|
||
body = "{}",
|
||
})
|
||
if err then
|
||
logEvent("领取失败 " .. plat.id .. ": " .. err)
|
||
return { status = 400, json = { error = err } }
|
||
end
|
||
|
||
local acct = plugin.state.accounts[plat.id] or {}
|
||
acct.last_claim = os.time and os.time() or 0
|
||
acct.last_claim_status = resp.status
|
||
plugin.state.accounts[plat.id] = acct
|
||
logEvent("领取返回 " .. tostring(resp.status))
|
||
return { status = 200, json = { ok = true, upstream_status = resp.status, body = resp.body } }
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 定时任务:自动领取
|
||
-- ============================================================================
|
||
--
|
||
-- 声明了 schedule 的插件会得到内核 ticker 驱动(无浏览器也执行),
|
||
-- 且任务跑在本插件自己的 VM 上 —— 所以任务里读 state/保险库与钩子看到的是同一份。
|
||
plugin.schedule = {
|
||
{ fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 },
|
||
}
|
||
|
||
function plugin.auto_claim()
|
||
for _, plat in ipairs(PLATFORMS) do
|
||
local acct = accountOf(plat.id)
|
||
if acct and acct.enabled then
|
||
plugin.claim_daily({ platform = plat.id })
|
||
end
|
||
end
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- 对外 HTTP 端点(设置页调用)
|
||
-- ============================================================================
|
||
|
||
plugin.http = {
|
||
{ path = "channels/platforms", method = "GET", handler = "list_platforms",
|
||
roles = { "admin" }, description = "平台清单与账号状态" },
|
||
{ path = "channels/login", method = "POST", handler = "start_login",
|
||
roles = { "admin" }, description = "发起某平台的登录" },
|
||
{ path = "channels/login/status", method = "GET", handler = "poll_login",
|
||
roles = { "admin" }, description = "轮询登录会话状态" },
|
||
{ path = "channels/login/complete", method = "POST", handler = "complete_login",
|
||
roles = { "admin" }, description = "登录完成:存凭据并接入源" },
|
||
{ path = "channels/claim", method = "POST", handler = "claim_daily",
|
||
roles = { "admin" }, description = "手动领取某平台每日额度" },
|
||
{ path = "channels/status", method = "GET", handler = "status_page",
|
||
roles = { "admin" }, description = "接入状态与最近日志" },
|
||
-- OAuth 回调落点:平台重定向回来带 code。
|
||
{ path = "channels/callback", method = "GET", handler = "oauth_callback",
|
||
public = false, roles = { "admin" }, description = "OAuth 回调" },
|
||
}
|
||
|
||
function plugin.list_platforms(req)
|
||
local out = {}
|
||
for _, p in ipairs(PLATFORMS) do
|
||
local acct = accountOf(p.id) or {}
|
||
local has = __llmsproxy_secrets.get("channels:" .. p.id)
|
||
out[#out + 1] = {
|
||
id = p.id, name = p.name, flow = p.flow,
|
||
logged_in = has ~= nil,
|
||
account = acct.account,
|
||
enabled = acct.enabled,
|
||
last_claim = acct.last_claim,
|
||
}
|
||
end
|
||
return { status = 200, json = { platforms = out } }
|
||
end
|
||
|
||
function plugin.status_page(req)
|
||
local r = __llmsproxy_kernel.call("status", {})
|
||
return { status = 200, json = {
|
||
accounts = plugin.state.accounts,
|
||
log = plugin.state.log,
|
||
kernel_ready = r and r.ready or false,
|
||
} }
|
||
end
|
||
|
||
-- oauth_callback 处理平台重定向:校验 nonce,然后交给 complete_login。
|
||
--
|
||
-- nonce 校验是必需的:没有它,别人构造一个回调就能把**自己的**凭据塞进
|
||
-- 这个网关的登录流程。
|
||
function plugin.oauth_callback(req)
|
||
local nonce = req.query and req.query.state
|
||
if not nonce or nonce == "" then
|
||
return { status = 400, body = "missing state" }
|
||
end
|
||
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
|
||
if s.flow ~= "callback" then
|
||
return { status = 400, body = "session is not a callback flow" }
|
||
end
|
||
local code = req.query.code
|
||
if not code or code == "" then
|
||
return { status = 400, body = "missing code" }
|
||
end
|
||
local res = plugin.complete_login({ platform = s.platform, credential = code, account = "oauth" })
|
||
if res.error then
|
||
return { status = 400, body = res.error }
|
||
end
|
||
s.state = "completed"
|
||
s.account = "oauth"
|
||
return { status = 200, body = "ok" }
|
||
end
|
||
|
||
-- plugin.on_unload:插件被卸载/重载时回收自己拉起的浏览器栈。进程是插件起的,
|
||
-- 收尾也是插件的责任 —— 内核不应知道这些进程的存在。
|
||
plugin.on_unload = function()
|
||
reapBrowser()
|
||
end
|
||
|
||
-- ============================================================================
|
||
-- UI:独立标签页(登录 / 认证 / 配置)
|
||
-- ============================================================================
|
||
|
||
plugin.ui = {
|
||
page = {
|
||
page_id = "channels",
|
||
title = "账号接入",
|
||
icon = "🔌",
|
||
order = 40,
|
||
mount = [[
|
||
<div id="channels-page">
|
||
<div class="card">
|
||
<h2>平台接入</h2>
|
||
<p class="muted">登录在你自己的浏览器完成;凭据加密保存在服务端,凭据永不回显。</p>
|
||
<div id="ch-list" class="tbl-wrap"></div>
|
||
</div>
|
||
<div class="card" id="ch-login-box" style="display:none">
|
||
<h2 id="ch-login-title">登录</h2>
|
||
<div id="ch-login-body"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>最近日志</h2>
|
||
<ul id="ch-log" style="font-size:12px"></ul>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
(function () {
|
||
const q = (s) => document.querySelector(s);
|
||
const esc = (v) => String(v == null ? "" : v).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||
async function call(path, method, body) {
|
||
const r = await fetch("/api/plugins/channels/http/" + path, {
|
||
method: method || "GET",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "same-origin",
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
return await r.json().catch(() => ({}));
|
||
}
|
||
let pollTimer = null;
|
||
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
|
||
|
||
async function refresh() {
|
||
const j = await call("channels/platforms");
|
||
const rows = (j.platforms || []).map((p) => {
|
||
const st = p.logged_in ? '<span class="tag tag-green">已登录</span>' : '<span class="tag">未登录</span>';
|
||
const done = p.logged_in
|
||
? '<button class="small" onclick="chClaim(\'' + esc(p.id) + '\')">领取额度</button>'
|
||
: '<button class="small" onclick="chLogin(\'' + esc(p.id) + '\')">登录</button>';
|
||
return "<tr><td><b>" + esc(p.name) + "</b><div class='muted'>" + esc(p.flow) + "</div></td>" +
|
||
"<td>" + st + "</td><td>" + esc(p.account || "") + "</td><td>" + done + "</td></tr>";
|
||
}).join("");
|
||
q("#ch-list").innerHTML = "<table><tr><th>平台</th><th>状态</th><th>账号</th><th></th></tr>" +
|
||
(rows || "<tr><td colspan=4 class='empty'>无平台</td></tr>") + "</table>";
|
||
const sj = await call("channels/status");
|
||
q("#ch-log").innerHTML = (sj.log || []).map((e) => "<li>" + esc(e.msg) + "</li>").join("");
|
||
}
|
||
|
||
window.chLogin = async function (id) {
|
||
const j = await call("channels/login", "POST", { platform: id });
|
||
if (j.error) { alert(j.error); return; }
|
||
const box = q("#ch-login-box"); box.style.display = "block";
|
||
q("#ch-login-title").textContent = "登录:" + id;
|
||
const vnc = q("#ch-vnc-wrap");
|
||
if (j.view_url) {
|
||
// 服务端浏览器:内嵌在本页签里
|
||
q("#ch-login-body").innerHTML =
|
||
"<p class='muted' id='ch-login-state'>请在下方窗口完成登录…</p>";
|
||
vnc.style.display = "block";
|
||
q("#ch-vnc").src = j.view_url;
|
||
} else {
|
||
// 用户自己的浏览器足够(link-poll / callback / paste)
|
||
vnc.style.display = "none";
|
||
q("#ch-login-body").innerHTML =
|
||
"<p>请在你自己的浏览器打开下面的地址完成登录:</p>" +
|
||
"<p><a href='" + esc(j.url) + "' target='_blank' rel='noopener'>" + esc(j.url) + "</a></p>" +
|
||
(j.session && j.session.user_code ? "<p>设备码:<b>" + esc(j.session.user_code) + "</b></p>" : "") +
|
||
"<p class='muted' id='ch-login-state'>等待完成…</p>";
|
||
}
|
||
stopPoll();
|
||
pollTimer = setInterval(async () => {
|
||
const st = await call("channels/login/status?id=" + encodeURIComponent(j.session.id));
|
||
const el = q("#ch-login-state"); if (el) el.textContent = "状态:" + st.state;
|
||
if (st.state === "completed" || st.state === "failed" || st.state === "expired") {
|
||
stopPoll(); q("#ch-vnc").src = "about:blank"; vnc.style.display = "none";
|
||
box.style.display = "none"; refresh();
|
||
}
|
||
}, 2000);
|
||
};
|
||
|
||
window.chClaim = async function (id) {
|
||
const j = await call("channels/claim", "POST", { platform: id });
|
||
alert(j.error ? ("失败: " + j.error) : ("领取返回 " + j.status));
|
||
refresh();
|
||
};
|
||
|
||
refresh();
|
||
})();
|
||
</script>
|
||
]],
|
||
},
|
||
}
|
||
|
||
return plugin
|