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,242 @@
package core
import (
"encoding/json"
"testing"
"llmsproxy/internal/lua"
)
// TestChannelsPluginEndToEnd 是「同类插件」的完整验收:
// 内置的 channels 插件在**真实内核**上跑通「登录 → 存凭据 → 接入为上游源」。
//
// 这条链路横跨本次所有新增能力,所以它是最有信息量的一个测试:
//
// __llmsproxy_login 创建/轮询登录会话
// __llmsproxy_secrets 凭据进保险库(不进 state)
// __llmsproxy_kernel add_source 把平台注册为上游源
// 内核权限 默认拒绝,需运维授权
// 登录主机授权 登录域不在源列表里,默认被出站白名单拒绝
func TestChannelsPluginEndToEnd(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatalf("读取内置 channels 插件: %v", err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatalf("加载 channels: %v", err)
}
// 1. 平台清单可用(未授权任何东西也应能列出)
out, err := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{
Method: "GET", Role: "admin",
})
if err != nil {
t.Fatalf("list_platforms: %v", err)
}
body, _ := out.JSON.(map[string]interface{})
plats, _ := body["platforms"].([]interface{})
if len(plats) < 3 {
t.Fatalf("应至少 3 个平台, 实得 %d", len(plats))
}
// 2. 发起登录 → 拿到会话与 URL
out, err = ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"example"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
m := out.JSON.(map[string]interface{})
if m["error"] != nil {
t.Fatalf("发起登录失败: %v", m["error"])
}
if m["url"] == nil || m["url"] == "" {
t.Fatal("应返回用户要打开的登录 URL")
}
sess := m["session"].(map[string]interface{})
id, _ := sess["id"].(string)
if id == "" {
t.Fatal("应返回会话 id")
}
// 3. 轮询到 pending
out, _ = ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": id}, Role: "admin",
})
if st := out.JSON.(map[string]interface{})["state"]; st != "pending" {
t.Fatalf("应为 pending, 实得 %v", st)
}
// 4. 未授权内核动作时,接入源必须失败且**如实报告**(不是假装成功)
out, err = ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"example","credential":"tok-1","account":"alice"}`, Role: "admin",
})
if err != nil {
t.Fatalf("complete_login: %v", err)
}
done := out.JSON.(map[string]interface{})
if done["source_error"] == nil {
t.Fatal("未授权 add_source 时应报告 source_error")
}
t.Logf("未授权时的如实报告: %v", done["source_error"])
// 凭据必须已在保险库(登录本身成功了)
tok, ok, _ := ps.GetSecret("channels", "channels:example")
if !ok || tok != "tok-1" {
t.Fatalf("凭据应已存保险库: ok=%v tok=%q", ok, tok)
}
// 且不得出现在 state 里
if stateHasSecret(ps, "channels", "tok-1") {
t.Fatal("★ 凭据泄漏进了 plugin.state")
}
// 5. 运维授权后,重新接入应当真的把源注册进内核
if err := ps.SetPluginKernelPermission("channels",
[]string{"add_source", "list_sources", "status"}, false); err != nil {
t.Fatal(err)
}
out, err = ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"example","credential":"tok-1","account":"alice"}`, Role: "admin",
})
if err != nil {
t.Fatalf("complete_login(授权后): %v", err)
}
done = out.JSON.(map[string]interface{})
if done["source_error"] != nil {
t.Fatalf("授权后仍失败: %v", done["source_error"])
}
if done["source"] != "channels-example" {
t.Fatalf("源名不对: %v", done["source"])
}
// 内核侧必须真的有这个源
found := false
for _, s := range c.Sources() {
if s.Name == "channels-example" {
found = true
if s.BaseURL != "https://api.example.com/v1" {
t.Errorf("base_url 未落库: %q", s.BaseURL)
}
}
}
if !found {
t.Fatal("插件接入的源没有出现在内核源列表里")
}
// 平台声明的模型应可路由
models := c.Registry().ModelList()
hasModel := false
for _, id := range models {
if id == "example-chat" {
hasModel = true
}
}
if !hasModel {
t.Errorf("平台模型未进入可路由列表: %v", models)
}
t.Logf("已接入源 channels-example,模型可见: %v", models)
}
// TestChannelsPluginDeclaresItsNeeds 断言插件如实声明了它需要什么。
//
// 声明是运维审批的依据:插件不声明,设置页就无法显示"申请中",
// 运维只能等某个功能静默失效后才发现。
func TestChannelsPluginDeclaresItsNeeds(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)
}
// 内核动作声明
views := ps.KernelPermissions()
var declared []string
for _, v := range views {
if v.Plugin == "channels" {
declared = v.Declared
}
}
if len(declared) == 0 {
t.Fatal("channels 应声明它需要的内核动作")
}
for _, want := range []string{"add_source", "status"} {
found := false
for _, d := range declared {
if d == want {
found = true
}
}
if !found {
t.Errorf("应声明 %q, 实得 %v", want, declared)
}
}
// 登录主机声明(非源 host,需单独批准)
hosts := ps.PluginAuthHosts("channels")
if len(hosts) == 0 {
t.Fatal("channels 应声明登录主机")
}
t.Logf("声明内核动作=%v 登录主机=%v", declared, hosts)
}
// TestChannelsPluginHandlersRequireStatus 是一个契约回归:插件的每个 handler
// 都必须返回 {status,json/body},否则内核会**静默丢弃**它(实测踩过:
// `return {url=…}` 得到 200 空响应,没有任何错误信息)。
func TestChannelsPluginHandlersRequireStatus(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", "status", "list_sources"}, false)
// 每个 handler 至少要在正常输入下给出非空响应
cases := []struct {
fn string
req *lua.HandlerRequest
what string
}{
{"list_platforms", &lua.HandlerRequest{Method: "GET"}, "平台列表"},
{"status_page", &lua.HandlerRequest{Method: "GET"}, "状态页"},
{"poll_login", &lua.HandlerRequest{Method: "GET", Query: map[string]string{"id": "nope"}}, "轮询(未知会话)"},
{"start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"example"}`}, "发起登录"},
{"start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"nope"}`}, "发起登录(未知平台)"},
{"complete_login", &lua.HandlerRequest{Method: "POST", Body: `{}`}, "完成登录(缺字段)"},
{"claim_daily", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"example"}`}, "领额度(未登录)"},
}
for _, tc := range cases {
out, err := ps.InvokeHandler("channels", tc.fn, tc.req)
if err != nil {
t.Errorf("%s (%s) 返回错误: %v", tc.fn, tc.what, err)
continue
}
if out.JSON == nil && out.Body == "" {
t.Errorf("%s (%s) 返回空响应 —— 内核会静默丢弃,调用方看不到任何原因",
tc.fn, tc.what)
}
}
}
// jsonMarshalForTest 序列化一个接口值,用于检查凭据是否出现在 state 里。
func jsonMarshalForTest(v interface{}) ([]byte, error) { return json.Marshal(v) }
// stateHasSecret 检查凭据是否泄漏进 plugin.state。
func stateHasSecret(ps *lua.Plugins, plugin, secret string) bool {
st := ps.State(plugin)
if st == nil {
return false
}
b, err := jsonMarshalForTest(st)
if err != nil {
return false
}
return contains(string(b), secret)
}

View File

@ -0,0 +1,110 @@
package gateway
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
// 服务端浏览器 viewer 的反向代理(R4-f)
//
// 用户指明的场景:有些平台的登录会**重定向到本机回环地址**,用户机器上的浏览器
// 收不到(那个 127.0.0.1 是用户自己的),所以必须在服务端跑浏览器。而那个浏览器
// 需要给用户一个能操作的界面 —— 这就是本文件。
//
// 架构:Xvfb(:N) ← chromium;x11vnc 把 :N 暴露成 VNC(:v);websockify 把 VNC 桥成
// WebSocket 并顺带伺服 noVNC 静态文件(:w)。网关把 :w 反代到
// /browser/<sessionID>/...
//
// 为什么反代而不是让用户直连 :w:
//
// 1. **鉴权**。直连等于把一个"已经登录着用户第三方账号"的浏览器暴露在网络上,
// 任何能访问该端口的人都能接着用那个会话。反代让它继承网关的登录态与角色。
// 2. **WebSocket**。noVNC 的核心是 WS 升级;httputil.ReverseProxy 从 Go 1.12
// 起原生处理 Upgrade,自己写会把这块写错。
// 3. **同源**。viewer 与网关同源,noVNC 的 WS 路径不需要跨域配置。
// browserMountPrefix 是 viewer 的挂载前缀。
const browserMountPrefix = "/browser/"
// isBrowserMount 判断请求是否属于 viewer。
func isBrowserMount(p string) bool { return strings.HasPrefix(p, browserMountPrefix) }
// handleBrowserMount 把 /browser/<id>/... 反代到该会话的 websockify。
//
// 鉴权沿用网关的通用路径(Handler 外层已做 auth),因此只有已登录的运维能打开;
// 这里额外要求 admin 角色:能操作一个已登录浏览器的人等价于能操作被登录的账号。
func (g *Gateway) handleBrowserMount(w http.ResponseWriter, r *http.Request) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
rest := strings.TrimPrefix(r.URL.Path, browserMountPrefix)
id := rest
if i := strings.Index(rest, "/"); i >= 0 {
id = rest[:i]
}
if id == "" {
writeError(w, http.StatusNotFound, "not_found", "browser session id required")
return
}
ps := g.core.Plugins()
if ps == nil {
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
return
}
sess, ok := ps.GetBrowserSession(id)
if !ok {
// An expired session is torn down on read, so "not found" is the honest
// answer: the browser and its cookies are gone.
writeError(w, http.StatusNotFound, "not_found", "no such browser session (it may have expired)")
return
}
if sess.WSPort == 0 {
writeError(w, http.StatusInternalServerError, "internal", "browser session has no viewer port")
return
}
target := &url.URL{Scheme: "http", Host: "127.0.0.1:" + itoa(sess.WSPort)}
proxy := httputil.NewSingleHostReverseProxy(target)
// The session id is stripped from the path before proxying, so websockify
// sees the SAME paths it serves locally ("/vnc.html", "/websockify").
// Without this the viewer's bundled JS would build a WS URL containing the
// gateway prefix and websockify would 404 the upgrade.
orig := r.URL.Path
prefixLen := len(browserMountPrefix) + len(id)
if prefixLen < len(orig) {
r.URL.Path = orig[prefixLen:]
} else {
r.URL.Path = "/"
}
r.URL.RawPath = ""
// Preserve the browser-facing Host so noVNC's relative URLs stay on the
// gateway origin.
r.Header.Set("X-Forwarded-Prefix", browserMountPrefix+id)
// A failed upgrade is the common failure (a stale port after teardown), and
// the default proxy error page says nothing useful.
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, http.StatusBadGateway, "browser_unavailable",
"the browser viewer is not reachable: "+err.Error())
}
proxy.ServeHTTP(w, r)
}
// itoa is a tiny integer formatter for port numbers.
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}

View File

@ -0,0 +1,69 @@
package gateway
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestBrowserViewerIsProxiedAndAuthenticated 验证服务端浏览器 viewer 的反代。
//
// viewer 一定要经网关反代而不是让用户直连 websockify 端口:
// - 直连等于把一个"已登录用户第三方账号"的浏览器暴露在网络上
// - noVNC 的核心是 WebSocket 升级,反代由 httputil 原生处理
// - 同源让 noVNC 的 WS 路径不需要跨域配置
func TestBrowserViewerIsProxiedAndAuthenticated(t *testing.T) {
g := newTestGateway(t)
c := g.core
ps := c.Plugins()
if ps == nil {
t.Fatal("无 plugins")
}
ps.LoadSource("bp", `local plugin = { name = "bp", version = "1.0" } return plugin`)
ps.SetVNCEnabled(true)
sess, err := ps.StartBrowserSession("bp", "example", "about:blank", 0)
if err != nil {
t.Fatalf("启动浏览器会话: %v", err)
}
defer ps.StopBrowserSession(sess.ID)
time.Sleep(4 * time.Second) // 让 websockify 起来
ts := httptest.NewServer(g.Handler())
defer ts.Close()
// 不带 key → 401(viewer 继承网关鉴权)
// 关键:不要跟随重定向,否则拿到的是登录页(200)而不是真实判定
noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := noFollow.Get(ts.URL + sess.ViewURL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
t.Logf("未鉴权访问: status=%d location=%q", resp.StatusCode, resp.Header.Get("Location"))
if resp.StatusCode == 200 {
t.Errorf("★ 未鉴权访问 viewer 竟然 200")
}
// 带 admin key → 应拿到 noVNC 页面
req, _ := http.NewRequest("GET", ts.URL+"/browser/"+sess.ID+"/vnc.html", nil)
req.Header.Set("Authorization", "Bearer sk-test")
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
buf := make([]byte, 4096)
n, _ := resp.Body.Read(buf)
body := string(buf[:n])
if resp.StatusCode != 200 {
t.Fatalf("viewer 应 200, 实得 %d: %s", resp.StatusCode, body)
}
if !strings.Contains(strings.ToLower(body), "novnc") && !strings.Contains(body, "<html") {
t.Errorf("viewer 内容不像 noVNC 页面: %s", body[:200])
}
t.Logf("viewer 返回 %d 字节, 首行: %s", n, strings.SplitN(body, "\n", 2)[0])
}

View File

@ -260,6 +260,11 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
g.handleCustomMount(w, r)
case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"):
g.handlePluginsAPI(w, r)
case isBrowserMount(r.URL.Path):
// Server-side browser viewer (VNC-over-WebSocket via noVNC). Checked
// before the /api catch-alls because its path is outside them, and
// before the UI catch-all so it is not served as a static file.
g.handleBrowserMount(w, r)
case r.URL.Path == "/api/chat":
g.handleChat(w, r)
case r.URL.Path == "/api/status":

View File

@ -266,9 +266,96 @@ func registerLoginBridge(L *golua.State, ps *Plugins, name string) {
L.SetField(-2, "list")
L.PushGoClosure(luaLoginCancel(ps, name))
L.SetField(-2, "cancel")
// 服务端浏览器(回环类登录的唯一自动化路径)。与登录会话同一张表,
// 因为它就是登录的一种流程,而不是独立能力。
L.PushGoClosure(luaBrowserStart(ps, name))
L.SetField(-2, "start_browser")
L.PushGoClosure(luaBrowserGet(ps, name))
L.SetField(-2, "get_browser")
L.PushGoClosure(luaBrowserStop(ps, name))
L.SetField(-2, "stop_browser")
L.SetGlobal(loginGlobal)
}
// luaBrowserStart: start_browser({platform, start_url, callback_port}) ->
//
// ({view_url, id, callback_port}, nil) | (nil, err)
//
// The plugin gets a VIEW URL, never a browser-control handle. That is the
// deliberate boundary: a plugin that could drive the browser arbitrarily could
// read every tab's cookies, and the browser is logged into the OPERATOR's
// third-party accounts, not the plugin's own.
func luaBrowserStart(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
args, err := luaTableArg(L, 1)
if err != nil {
L.PushNil()
L.PushString("__llmsproxy_login.start_browser: " + err.Error())
return 2
}
port := 0
if n, ok := asFloat(args["callback_port"]); ok {
port = int(n)
}
sess, err := ps.StartBrowserSession(name, asString(args["platform"]),
asString(args["start_url"]), port)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
pushGoValue(L, browserToLua(*sess))
L.PushNil()
return 2
}
}
// luaBrowserGet: get_browser(id) -> (session, nil) | (nil, nil)
func luaBrowserGet(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
id := L.ToString(1)
s, ok := ps.GetBrowserSession(id)
if !ok || s.Plugin != name {
L.PushNil()
L.PushNil()
return 2
}
pushGoValue(L, browserToLua(*s))
L.PushNil()
return 2
}
}
// luaBrowserStop: stop_browser(id) -> (true, nil)
func luaBrowserStop(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
id := L.ToString(1)
if s, ok := ps.GetBrowserSession(id); ok && s.Plugin == name {
ps.StopBrowserSession(id)
}
L.PushBoolean(true)
L.PushNil()
return 2
}
}
// browserToLua renders a browser session for Lua. The CDP/debug ports are NOT
// included: the plugin receives what it needs to display the viewer and read the
// captured code, and nothing that would let it drive the browser.
func browserToLua(s BrowserSession) map[string]interface{} {
return map[string]interface{}{
"id": s.ID,
"platform": s.Platform,
"view_url": s.ViewURL,
"callback_port": s.CallbackPort,
"captured_code": s.CapturedCode,
"state": s.CapturedState,
"error": s.Error,
"started_at": s.StartedAt.Unix(),
"expires_at": s.ExpiresAt.Unix(),
}
}
// luaLoginBegin: begin({platform, flow, url, user_code}) -> (session, nil) | (nil, err)
func luaLoginBegin(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {

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)
}

View File

@ -0,0 +1,57 @@
package lua
import "testing"
// TestServerBrowserFlowThroughPlugin 是 VNC 登录流程的端到端断言。
//
// 背景(用户指明的场景):有些平台把授权重定向到 127.0.0.1。用户机器上的浏览器
// 收不到(那个回环是**用户自己的**),所以浏览器必须跑在服务端 —— 此时重定向才会
// 落到本进程的监听器上(已实测验证)。
//
// 用户要求:这个浏览器窗口应**内嵌在插件对应的页签下**,所以插件拿到的是
// view_url(iframe src),而不是让人跳出去开新窗口。
func TestServerBrowserFlowThroughPlugin(t *testing.T) {
_, ps, _ := runtimePlugins(t)
src, _ := ReadBundledPlugin("channels")
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// 默认关闭时:应给出明确提示而不是假装成功
out, err := ps.InvokeHandler("channels", "start_login", &handlerRequest{
Method: "POST", Body: `{"platform":"loopbackish"}`,
})
if err != nil {
t.Fatal(err)
}
m := out.JSON.(map[string]interface{})
// 默认关闭时在 begin 阶段就被拒(更早、更明确),而不是等到起浏览器才失败
if m["error"] == nil && m["browser_error"] == nil {
t.Fatalf("默认关闭 VNC 时应被拒, 实得 %#v", m)
}
t.Logf("默认关闭时: error=%v browser_error=%v", m["error"], m["browser_error"])
// 开启后:应返回 view_url
ps.SetVNCEnabled(true)
out, err = ps.InvokeHandler("channels", "start_login", &handlerRequest{
Method: "POST", Body: `{"platform":"loopbackish"}`,
})
if err != nil {
t.Fatal(err)
}
m = out.JSON.(map[string]interface{})
if m["browser_error"] != nil {
t.Fatalf("开启后仍失败: %v", m["browser_error"])
}
vu, _ := m["view_url"].(string)
if vu == "" {
t.Fatal("应返回 view_url(供页签内嵌)")
}
t.Logf("★ 可内嵌的 view_url: %s", vu)
// 非回环平台不应起浏览器(等于白开一个高风险的浏览器)
out, _ = ps.InvokeHandler("channels", "start_login", &handlerRequest{
Method: "POST", Body: `{"platform":"example"}`,
})
if m2 := out.JSON.(map[string]interface{}); m2["view_url"] != nil {
t.Error("非回环平台不该起服务端浏览器")
}
}

View File

@ -315,6 +315,10 @@ type Plugins struct {
// Plugins built without construction still works.
loginStore *loginSessions
loginOnce sync.Once
// browserStore owns server-side browser sessions (plugin_browser.go). Lazy
// for the same reason as loginStore.
browserStore *browserSupervisor
browserOnce sync.Once
// sourceHosts remembers the source-derived allowlist so a rebuild can fold it
// together with any granted login hosts.
sourceHosts []string

View File

@ -0,0 +1,486 @@
-- channels:多平台账号接入(登录认证 → 凭据 → 自动领取额度 → 注册为上游源)
--
-- 为什么需要它
-- ------------
-- 网关的上游源(sources)一直是**运维手工配置**的:填 base_url、填 api_key。
-- 而现实里有大量平台是"注册个账号、每天领免费额度"的形态,它们的接入方式是
-- **登录**而不是"申请一个 key"。这类平台要接入网关,就需要:驱动登录 → 拿到
-- 凭据 → 定时领取额度 → 把端点当源用。
--
-- 本插件把这套流程做成通用的:一个插件内建平台表,每个平台声明自己的
-- 登录方式(link-poll / callback / paste)、额度领取接口、上游端点形状。
-- 新增一个平台是往 PLATFORMS 里加一条,不改内核。
--
-- 设计边界(与内核的约定)
-- ------------------------
-- 内核提供三样基础设施,插件自己不实现这些:
--
-- __llmsproxy_login 登录会话生命周期(create/poll/resolve)
-- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读)
-- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…)
--
-- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状。
-- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"——
-- 内核里出现厂商名就是分层被破坏了。
--
-- 权限(本插件需要,全部可在设置页看到"申请中")
-- ------------------------------------------------
-- add_source 把平台端点注册为上游源
-- list_sources 显示当前已接入的平台
-- probe_all 接入后立刻验证可达
-- reset_health 上游恢复后清冷却
-- status 面板显示运行状态
--
-- 登录主机也需批准(plugin.auth_hosts):登录页所在的域名不是上游源,
-- 默认会被出站白名单拒绝——这是刻意的。
local plugin = {
name = "channels",
version = "1.0.0",
description = "多平台账号接入:登录认证、凭据保管、自动领取额度、注册为上游源",
author = "llmsproxy",
}
-- 申请的内核权限。设置页会把"申请中但未授权"标红。
plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" }
-- 申请的登录主机(出站白名单)。内核里**不含**任何厂商名——这些只存在于插件。
plugin.auth_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 流程)或本插件轮询到结果
-- (link-poll 流程)。
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({
platform = plat.id,
flow = plat.flow,
url = plat.login_url,
})
if err then return { status = 400, json = { error = err } } end
logEvent("开始登录 " .. plat.name)
-- 回环类平台:必须在**服务端**开浏览器。
-- 理由:授权重定向打到 127.0.0.1,用户机器上的那个是**用户自己的**回环,
-- 服务端收不到 code。只有浏览器跑在服务端,重定向才落到本进程的监听器上。
local view_url
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,
})
if berr then
logEvent("服务端浏览器启动失败: " .. berr)
return {
status = 200,
json = {
url = s.url, session = s, platform = plat.id,
browser_error = berr,
hint = "服务端浏览器未启用或不可用;可在设置页开启 VNC 流程(默认关闭)",
},
}
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,
} }
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)
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,
}
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
local s = __llmsproxy_login.get(nonce)
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
__llmsproxy_login.resolve(s.id, "completed", "oauth", "")
return { status = 200, body = "ok" }
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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