mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户指明的场景修正了我的一个实质错误。我之前写"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 契约。
243 lines
7.8 KiB
Go
243 lines
7.8 KiB
Go
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)
|
||
}
|