From fa50ec8bc71b681a0367e1475dc6ec6da52739ac Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 11 Oct 2026 14:09:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(lua):=20=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E7=99=BB=E5=BD=95=EF=BC=88VNC?= =?UTF-8?q?=EF=BC=89+=20=E5=86=85=E5=B5=8C=E9=A1=B5=E7=AD=BE=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=9B=9E=E7=8E=AF=E5=9B=9E=E8=B0=83=E7=9A=84?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E8=87=AA=E5=8A=A8=E5=8C=96=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户指明的场景修正了我的一个实质错误。我之前写"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//... → 该会话的 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 契约。 --- internal/core/channels_plugin_test.go | 242 ++++++++++++ internal/gateway/browser_proxy.go | 110 ++++++ internal/gateway/browser_proxy_test.go | 69 ++++ internal/gateway/server.go | 5 + internal/lua/plugin_bridges.go | 87 +++++ internal/lua/plugin_browser.go | 388 ++++++++++++++++++++ internal/lua/plugin_browser_test.go | 57 +++ internal/lua/plugins.go | 4 + internal/lua/plugins/channels.lua | 486 +++++++++++++++++++++++++ 9 files changed, 1448 insertions(+) create mode 100644 internal/core/channels_plugin_test.go create mode 100644 internal/gateway/browser_proxy.go create mode 100644 internal/gateway/browser_proxy_test.go create mode 100644 internal/lua/plugin_browser.go create mode 100644 internal/lua/plugin_browser_test.go create mode 100644 internal/lua/plugins/channels.lua diff --git a/internal/core/channels_plugin_test.go b/internal/core/channels_plugin_test.go new file mode 100644 index 0000000..5aba278 --- /dev/null +++ b/internal/core/channels_plugin_test.go @@ -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) +} diff --git a/internal/gateway/browser_proxy.go b/internal/gateway/browser_proxy.go new file mode 100644 index 0000000..debf329 --- /dev/null +++ b/internal/gateway/browser_proxy.go @@ -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//... +// +// 为什么反代而不是让用户直连 :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//... 反代到该会话的 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:]) +} diff --git a/internal/gateway/browser_proxy_test.go b/internal/gateway/browser_proxy_test.go new file mode 100644 index 0000000..8b4b359 --- /dev/null +++ b/internal/gateway/browser_proxy_test.go @@ -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, " +// +// ({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 { diff --git a/internal/lua/plugin_browser.go b/internal/lua/plugin_browser.go new file mode 100644 index 0000000..6889513 --- /dev/null +++ b/internal/lua/plugin_browser.go @@ -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, "

Login received. You can close this tab.

") + }) + 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) +} diff --git a/internal/lua/plugin_browser_test.go b/internal/lua/plugin_browser_test.go new file mode 100644 index 0000000..5f82fec --- /dev/null +++ b/internal/lua/plugin_browser_test.go @@ -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("非回环平台不该起服务端浏览器") + } +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 6ef881b..b6f650c 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -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 diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua new file mode 100644 index 0000000..e31f0da --- /dev/null +++ b/internal/lua/plugins/channels.lua @@ -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 = [[ +
+
+

平台接入

+

登录在你自己的浏览器完成;凭据加密保存在服务端,凭据永不回显。

+
+
+ +
+

最近日志

+
    +
    +
    + +]], + }, +} + +return plugin