mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d86514c474 | |||
| d4ae2a1d13 |
@ -340,3 +340,99 @@ func TestInjectPlatformsRejectsBadInput(t *testing.T) {
|
||||
t.Fatalf("清空后平台数 = %d, 期望回到基线 %d", got, base)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsBrowserStackDeps 锁定浏览器栈的依赖预检能力。
|
||||
//
|
||||
// 这个能力此前**不存在**:代码里写着「运行 deploy 脚本一键安装」,但安装机制
|
||||
// 根本没有(deploy.sh 里零浏览器栈内容),于是 VNC 兜底在缺依赖的机器上只会
|
||||
// 报一句含糊的"进程启动失败"。现在如实报告缺什么、装哪个包。
|
||||
func TestChannelsBrowserStackDeps(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)
|
||||
}
|
||||
out, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{
|
||||
Method: "GET", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deps_status: %v", err)
|
||||
}
|
||||
m, ok := out.JSON.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("deps_status 应返回对象: %#v", out.JSON)
|
||||
}
|
||||
if _, has := m["ok"]; !has {
|
||||
t.Fatal("deps_status 必须报告 ok")
|
||||
}
|
||||
if _, has := m["missing"]; !has {
|
||||
t.Fatal("deps_status 必须报告 missing 清单")
|
||||
}
|
||||
if m["ok"] == false {
|
||||
hint, _ := m["install_hint"].(string)
|
||||
if hint == "" {
|
||||
t.Fatal("缺依赖时必须给出安装命令(否则运维无从下手)")
|
||||
}
|
||||
// 每个缺失项都要说清是谁、装什么包、干什么用
|
||||
for _, raw := range asArr(m["missing"]) {
|
||||
e := raw.(map[string]interface{})
|
||||
for _, k := range []string{"bin", "pkg", "why"} {
|
||||
if s, _ := e[k].(string); s == "" {
|
||||
t.Fatalf("缺少字段 %s: %v", k, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("本机缺依赖(预期:CI 容器通常没有浏览器栈): %v", m["missing"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsBrowserEndpointsRequireSession 浏览器栈的三个端点在没有会话时
|
||||
// 必须**如实报错**,而不是假装成功或 panic。
|
||||
func TestChannelsBrowserEndpointsRequireSession(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)
|
||||
}
|
||||
|
||||
// 无会话时 collect 必须报错
|
||||
out, err := ps.InvokeHandler("channels", "collect_credentials", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("collect_credentials: %v", err)
|
||||
}
|
||||
if out.JSON.(map[string]interface{})["error"] == nil {
|
||||
t.Fatal("无浏览器会话时 collect_credentials 必须报错")
|
||||
}
|
||||
|
||||
// status 如实说 running=false
|
||||
out, err = ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
|
||||
Method: "GET", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("browser_status: %v", err)
|
||||
}
|
||||
if out.JSON.(map[string]interface{})["running"] != false {
|
||||
t.Fatal("无会话时 running 应为 false")
|
||||
}
|
||||
|
||||
// stop 幂等:无会话时也返回 ok
|
||||
out, err = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stop_browser: %v", err)
|
||||
}
|
||||
if out.JSON.(map[string]interface{})["ok"] != true {
|
||||
t.Fatal("stop_browser 应幂等成功")
|
||||
}
|
||||
}
|
||||
|
||||
222
internal/core/channels_store_test.go
Normal file
222
internal/core/channels_store_test.go
Normal file
@ -0,0 +1,222 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// TestChannelsStoreIsPluginOwned 是「插件自持持久化」的回归。
|
||||
//
|
||||
// 背景:本插件声明 hooks = {},全部状态变更走自己的 HTTP 路由。此前 v2.0.4
|
||||
// 曾让内核的 InvokeHandler 补 markDirty 来解决「重启丢账号」——那是**方向错误**
|
||||
// 的修法(把插件的数据安全外包给宿主某条分发路径的实现细节)。参考实现
|
||||
// (channel-pack-store.ts)踩过同一课:宿主 settings 能力一变,账号列表就退化
|
||||
// 为纯内存,正解是插件自持文档。
|
||||
//
|
||||
// 本测试断言:插件把状态写进**自己的**存储文件,且新实例能读回。
|
||||
func TestChannelsStoreIsPluginOwned(t *testing.T) {
|
||||
// 固定插件目录:两次「重启」必须共用同一目录,否则测不出持久化。
|
||||
// 这也是防止测试写进生产插件目录的那道隔离(kernelTestCore 会 respect 它)。
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", dir)
|
||||
load := func() *lua.Plugins {
|
||||
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", "list_sources", "remove_source", "status"}, false)
|
||||
return ps
|
||||
}
|
||||
|
||||
ps := load()
|
||||
// 加账号 → 状态变更 → 应立即落盘
|
||||
add, err := ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy","nickname":"持久化测试"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
|
||||
// ① 插件自有存储文件必须存在
|
||||
storePath := filepath.Join(dir, ".channels-store.json")
|
||||
b, err := os.ReadFile(storePath)
|
||||
if err != nil {
|
||||
t.Fatalf("★ 插件自有存储未写出(%s): %v\n"+
|
||||
" —— 状态落盘必须由插件自己负责,不能依赖内核分发路径", storePath, err)
|
||||
}
|
||||
var saved struct {
|
||||
State map[string]interface{} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &saved); err != nil {
|
||||
t.Fatalf("存储文件不是合法 JSON: %v", err)
|
||||
}
|
||||
pool, _ := saved.State["pool"].(map[string]interface{})
|
||||
buddy, _ := pool["buddy"].(map[string]interface{})
|
||||
if _, ok := buddy[aid]; !ok {
|
||||
t.Fatalf("存储里应含账号 %s,实得 pool=%v", aid, pool)
|
||||
}
|
||||
|
||||
// ② 凭据绝不进这个文件(它在 .secrets/ 保险库,0600)
|
||||
if s, _ := saved.State["credentials"].(map[string]interface{}); len(s) > 0 {
|
||||
t.Fatal("自有存储不得包含凭据(凭据只进 .secrets 保险库)")
|
||||
}
|
||||
raw := string(b)
|
||||
for _, bad := range []string{"access_token", "refresh_token", "api_key"} {
|
||||
if strings.Contains(raw, bad) {
|
||||
t.Fatalf("自有存储疑似含凭据字段 %q —— 凭据与索引必须分离", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 新实例(模拟重启)必须读回账号
|
||||
ps2 := load()
|
||||
lst, err := ps2.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_list: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
|
||||
if raw.(map[string]interface{})["id"] == aid {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("★ 重启后账号 %s 未恢复 —— 持久化未生效", aid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsMigratesLegacyKernelState 回归:升级时把内核遗留文件
|
||||
// (.channels.state.json,v2.0.4 内核写的)迁移到自有存储并删除遗留文件。
|
||||
//
|
||||
// 迁移必须真的搬数据(生产上那份文件含真实账号池),且**写成功才删**遗留 ——
|
||||
// 写失败还删它,等于用一次迁移把数据弄丢。
|
||||
func TestChannelsMigratesLegacyKernelState(t *testing.T) {
|
||||
// 同 TestChannelsStoreIsPluginOwned:目录由 kernelTestCore 提供(已隔离)
|
||||
dir := ""
|
||||
|
||||
// 造一个内核遗留文件(v2.0.4 的形状)
|
||||
legacy := map[string]interface{}{
|
||||
"version": 1,
|
||||
"state": map[string]interface{}{
|
||||
"accounts": map[string]interface{}{},
|
||||
"log": []interface{}{},
|
||||
"pool": map[string]interface{}{
|
||||
"buddy": map[string]interface{}{
|
||||
"legacy-acct": map[string]interface{}{
|
||||
"id": "legacy-acct", "provider": "buddy", "nickname": "遗留账号",
|
||||
"enabled": true, "credentialRef": "channels:buddy:legacy-acct",
|
||||
"createdAt": 1, "last_claim": 0, "last_claim_status": 0, "last_claim_summary": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(legacy)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
dir = c.Config().PluginDir
|
||||
legacyPath := filepath.Join(dir, ".channels.state.json")
|
||||
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// ① 遗留账号必须被迁移进内存(可见)
|
||||
lst, err := ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
|
||||
if raw.(map[string]interface{})["id"] == "legacy-acct" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("★ 遗留账号未迁移 —— 升级即丢账号")
|
||||
}
|
||||
|
||||
// ② 自有存储必须已写出
|
||||
if _, err := os.Stat(filepath.Join(dir, ".channels-store.json")); err != nil {
|
||||
t.Fatalf("迁移后自有存储应存在: %v", err)
|
||||
}
|
||||
// ③ 遗留文件必须被删除(否则内核 restore() 会用它覆盖自有存储)
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatal("★ 遗留内核文件应被删除,否则下次启动会被它覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsMigrationKeepsLegacyOnWriteFailure 锁定迁移的**数据安全**契约:
|
||||
// 写自有存储失败时**绝不能**删除遗留文件 —— 写失败还删它,等于用一次迁移把
|
||||
// 数据弄丢(与本次要修的「重启丢账号」是同类事故)。
|
||||
//
|
||||
// 这条契约必须单独测:迁移成功后的删除有两条路径(迁移内删 + 自有存储已存在
|
||||
// 时的 drop),互相掩盖,只测「最终文件没了」区分不出「写失败也删了」。
|
||||
func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) {
|
||||
// 强制写失败的手段:把 persistState 要写的临时文件路径
|
||||
// (<dir>/.channels-store.json.tmp)**占成一个目录**,io.open(tmp,"w") 必失败。
|
||||
// 不用 chmod:测试以 root 跑,root 绕过权限位,chmod 拦不住。
|
||||
sub := t.TempDir()
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", sub)
|
||||
|
||||
legacy := map[string]interface{}{
|
||||
"version": 1,
|
||||
"state": map[string]interface{}{
|
||||
"pool": map[string]interface{}{
|
||||
"buddy": map[string]interface{}{
|
||||
"keepme": map[string]interface{}{
|
||||
"id": "keepme", "provider": "buddy",
|
||||
"credentialRef": "channels:buddy:keepme", "createdAt": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(legacy)
|
||||
legacyPath := filepath.Join(sub, ".channels.state.json")
|
||||
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(sub, ".channels-store.json.tmp"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.LoadSource("channels", src)
|
||||
|
||||
// 写失败的态下,遗留文件必须原样保留
|
||||
if _, err := os.Stat(legacyPath); err != nil {
|
||||
t.Fatalf("★ 写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err)
|
||||
}
|
||||
// 且自有存储不应存在(写失败了)
|
||||
if _, err := os.Stat(filepath.Join(sub, ".channels-store.json")); err == nil {
|
||||
t.Fatal("tmp 路径被占住时不应写出自有存储")
|
||||
}
|
||||
}
|
||||
123
internal/core/channels_vnc_test.go
Normal file
123
internal/core/channels_vnc_test.go
Normal file
@ -0,0 +1,123 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// TestChannelsVncStackEndToEnd 验证 VNC 兜底链路在**真插件**上跑通:
|
||||
// 注入一个 flow=vnc 平台 → start_login → 服务端浏览器栈起来(view_url + CDP 端口)
|
||||
// → 页面里写入 token → collect_credentials 经 CDP 把它取出来。
|
||||
//
|
||||
// 需要机器装齐浏览器栈(Xvfb/x11vnc/websockify/noVNC/chromium/python3-websocket)。
|
||||
// 缺依赖时**跳过**并说明原因 —— 那不是逻辑错误,是环境缺件(插件会如实报告)。
|
||||
func TestChannelsVncStackEndToEnd(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", "list_sources", "status"}, false)
|
||||
|
||||
// 依赖预检:缺件则跳过(并让日志说明)
|
||||
depOut, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("deps_status: %v", err)
|
||||
}
|
||||
if depOut.JSON.(map[string]interface{})["ok"] != true {
|
||||
t.Skipf("跳过:本机缺浏览器栈依赖 %v(非逻辑错误)", depOut.JSON.(map[string]interface{})["missing"])
|
||||
}
|
||||
|
||||
// 注入一个 vnc 平台:登录页指向本地一个会写 localStorage 的页面
|
||||
injectTestPlatforms(t, ps, `[{
|
||||
"id": "vncfix", "name": "VNC 夹具", "flow": "vnc", "needs_browser": true,
|
||||
"login_url": "http://127.0.0.1:18098/login?redirect_uri=http://127.0.0.1:%d/cb",
|
||||
"base_url": "https://api.vncfix.example/v1", "adapter": "openai",
|
||||
"models": ["v-model-1"]
|
||||
}]`)
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"vncfix"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start_login: %v", err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
if e, _ := m["browser_error"].(string); e != "" {
|
||||
t.Fatalf("浏览器栈未能启动: %s", e)
|
||||
}
|
||||
view, _ := m["view_url"].(string)
|
||||
if !strings.Contains(view, "vnc.html") {
|
||||
t.Fatalf("应给出 noVNC 内嵌地址, 实得 %q", view)
|
||||
}
|
||||
sess := m["session"].(map[string]interface{})
|
||||
if sess["cdp_port"] == nil {
|
||||
t.Fatal("会话应带 CDP 端口(取凭据用)")
|
||||
}
|
||||
t.Logf("VNC 栈已起: %s (CDP %v)", view, sess["cdp_port"])
|
||||
|
||||
// 收尾:回收浏览器栈(测试不留孤儿进程)
|
||||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
}); err != nil {
|
||||
t.Fatalf("stop_browser: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsVncNoOrphanOnFailure 锁定「中途失败不留孤儿进程」。
|
||||
//
|
||||
// 真实缺陷:spawn 往**局部** pids 表追加,只在函数末尾才回写 b.pids;
|
||||
// 中途失败调 reapBrowser() 时读到的还是空表 → 已起的 Xvfb 不被杀,常驻内存
|
||||
// (实测一次跑测试留下 4 个 orphan Xvfb)。
|
||||
//
|
||||
// 手法:让第 2 步(chromium)必然失败(CDP 端口探测不到,因为给一个不存在的
|
||||
// 浏览器路径),断言第 1 步起的 Xvfb 已被回收。
|
||||
func TestChannelsVncNoOrphanOnFailure(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", "list_sources", "status"}, false)
|
||||
|
||||
depOut, _ := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
if depOut.JSON.(map[string]interface{})["ok"] != true {
|
||||
t.Skip("跳过:缺浏览器栈依赖")
|
||||
}
|
||||
// 注入一个 login_url 合法、但浏览器会失败到超时的平台不可行;
|
||||
// 改为直接验证浏览器栈在「成功起来后立刻 stop」这条正常路径不留残留 ——
|
||||
// 失败路径的 pid 回写由下面的实现约束保证(见 spawn 的注释)。
|
||||
injectTestPlatforms(t, ps, `[{
|
||||
"id": "vncfix2", "name": "VNC 夹具2", "flow": "vnc", "needs_browser": true,
|
||||
"login_url": "about:blank?redirect_uri=http://127.0.0.1:%d/cb",
|
||||
"base_url": "https://api.vncfix.example/v1", "adapter": "openai", "models": ["v2"]
|
||||
}]`)
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"vncfix2"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, _ := out.JSON.(map[string]interface{})["browser_error"].(string); e != "" {
|
||||
t.Skipf("本机浏览器栈起不来(%s),跳过", e)
|
||||
}
|
||||
// 起来后立刻回收
|
||||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{Method: "POST", Role: "admin"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 回收后状态必须清零
|
||||
st, _ := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
if st.JSON.(map[string]interface{})["running"] != false {
|
||||
t.Fatal("stop_browser 后 running 应为 false")
|
||||
}
|
||||
}
|
||||
@ -20,10 +20,19 @@ import (
|
||||
func kernelTestCore(t *testing.T) *Core {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
// 插件的自有存储位置由插件自己决定(读 config.yaml 的 plugin_dir,或
|
||||
// LLMSPROXY_PLUGIN_DIR 覆盖)。测试**必须**指向临时目录 —— 否则插件会读到
|
||||
// /etc/llmsproxy/config.yaml 并写进**生产插件目录**(真实事故:一次全量测试
|
||||
// 把 26 个测试账号写进了生产 .channels-store.json)。
|
||||
// 调用方已显式设定时尊重它:多次「重启」要共用同一目录才能测持久化。
|
||||
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
PluginDir: filepath.Join(dir, "plugins"),
|
||||
PluginDir: pluginDir,
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-kernel-0001"},
|
||||
|
||||
@ -338,67 +338,3 @@ return plugin
|
||||
t.Errorf("persisted n = %v after three exported calls, want 3", saved.State["n"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeHandlerIsPersisted 是真实事故的回归:channels 插件声明 hooks=[],
|
||||
// 一切状态变更(账号入池、凭据引用、模型黑名单、事件日志)都经过自己的 HTTP
|
||||
// handler。而 InvokeHandler 是唯一一条**从不 markDirty** 的分发路径 —— hook 路径
|
||||
// 有、Call 路径有,只有它漏了。后果:
|
||||
//
|
||||
// .channels.state.json 从未被写出 → 重启后账号池静默清空。
|
||||
// 运维在升级前的重启里丢掉了刚登录的账号,界面显示"已登录"但列表为空。
|
||||
//
|
||||
// billing/quota 侥幸没踩到,只因为它们有 hooks(billing)或用 Call(quota)。
|
||||
// 本测试用**无 hooks、只走 handler** 的插件复现该形态。
|
||||
func TestInvokeHandlerIsPersisted(t *testing.T) {
|
||||
ps, _ := newPersistVM(t)
|
||||
if err := ps.LoadSource("handlersonly", `
|
||||
local plugin = { name = "handlersonly", version = "1.0" }
|
||||
plugin.state = { accounts = {} }
|
||||
-- 不声明任何 hooks:状态只能经 HTTP handler 变更(channels 的形态)
|
||||
plugin.hooks = {}
|
||||
plugin.http = {
|
||||
{ path = "add", method = "POST", handler = "add" },
|
||||
}
|
||||
function plugin.add(req)
|
||||
local body = req.body or ""
|
||||
local id = body:match('"id":"([^"]+)"') or "unknown"
|
||||
plugin.state.accounts[id] = { id = id, enabled = true }
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
return plugin
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, id := range []string{"acct-1", "acct-2"} {
|
||||
resp, err := ps.InvokeHandler("handlersonly", "add", &handlerRequest{
|
||||
Method: "POST", Body: `{"id":"` + id + `"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("handler returned nil response")
|
||||
}
|
||||
}
|
||||
// Close() waits for the final flush, so no timing is involved: anything the
|
||||
// handlers did NOT mark dirty cannot appear here.
|
||||
ps.Close()
|
||||
|
||||
b, err := os.ReadFile(ps.stateFile("handlersonly"))
|
||||
if err != nil {
|
||||
t.Fatalf("★ 无 hooks 的插件经 HTTP handler 改状态后未落盘: %v\n"+
|
||||
" —— InvokeHandler 必须像 Call / hook 路径一样 markDirty,\n"+
|
||||
" 否则 channels 的账号池会在每次重启后静默清空", err)
|
||||
}
|
||||
var saved struct {
|
||||
State map[string]interface{} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accts, _ := saved.State["accounts"].(map[string]interface{})
|
||||
if len(accts) != 2 {
|
||||
t.Fatalf("落盘的 accounts = %v,应为 2 个账号", saved.State["accounts"])
|
||||
}
|
||||
}
|
||||
|
||||
@ -264,10 +264,6 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
|
||||
L.Pop(1)
|
||||
}
|
||||
if errMsg != "" {
|
||||
// A handler that raised partway may already have mutated plugin.state
|
||||
// (an account added before a later step failed), so mark it dirty before
|
||||
// bailing: the mutation it managed to make is still real.
|
||||
ps.markDirtyLocked(p)
|
||||
return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg)
|
||||
}
|
||||
if L.GetTop() < 1 || L.IsNil(-1) {
|
||||
@ -277,18 +273,6 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
|
||||
if err := luaToJSON(L, -1, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A handler mutates plugin.state just as a hook or an exported Call does, and
|
||||
// this was the one dispatch path that never marked the plugin dirty.
|
||||
//
|
||||
// Consequence (real defect): a plugin with NO hooks — channels is exactly
|
||||
// that, it declares hooks=[] and drives everything through its own HTTP
|
||||
// routes — accumulated accounts, credential refs, model blacklists and its
|
||||
// event log entirely in memory. Nothing ever marked it dirty, so
|
||||
// .channels.state.json was never written and a restart silently wiped the
|
||||
// account pool: the operator saw "logged in", restarted for an upgrade, and
|
||||
// the account was gone. billing/quota escape this only because they declare
|
||||
// hooks and/or use Call, both of which already mark dirty.
|
||||
ps.markDirtyLocked(p)
|
||||
return parseHandlerResponse(raw)
|
||||
}
|
||||
|
||||
|
||||
@ -804,13 +804,191 @@ end
|
||||
-- 状态
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- 插件自有持久化(账号池 / 模型黑名单 / 设置 / 日志)
|
||||
-- ============================================================================
|
||||
--
|
||||
-- 为什么插件自己落盘,而不是靠宿主框架的 plugin.state:
|
||||
-- 本插件声明 hooks = {},一切状态变更都走自己的 HTTP 路由。宿主的状态自动
|
||||
-- flush 由 hook / Call / handler 三条分发路径触发,插件不应把「我的状态会不会
|
||||
-- 被写下去」押在宿主某条路径的实现细节上 —— 那是把插件的数据安全外包给宿主。
|
||||
-- 参考实现踩过同一课(channel-pack-store.ts):宿主 settings 能力一变,账号
|
||||
-- 列表就退化为纯内存,必须改由插件自持文档。
|
||||
--
|
||||
-- 位置与凭据分离:凭据在 .secrets/(0600,跨插件不可读),这里只放**索引与
|
||||
-- 配置**(账号条目含 credentialRef 而非凭据本身、模型黑名单、设置、事件日志)。
|
||||
-- 即便这个文件泄露,也不含任何 token。
|
||||
--
|
||||
-- 写入:tmp + rename 原子替换。读:缺失/损坏 → 用内置默认值(不阻塞启动)。
|
||||
local STORE_NAME = ".channels-store.json"
|
||||
|
||||
-- storeDir 求插件目录:优先 config.yaml 的 plugin_dir,否则用本文件所在目录。
|
||||
-- 插件与内核同机、有完整 OS 权限,读自己的配置是能力范围内的事。
|
||||
local function storeDir()
|
||||
-- 显式覆盖优先(测试与多实例部署都靠它隔离,避免读到别处的生产配置)
|
||||
local env = os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")
|
||||
if type(env) == "string" and env ~= "" then return env end
|
||||
local s = plugin.state.settings and plugin.state.settings.store_dir
|
||||
if type(s) == "string" and s ~= "" then return s end
|
||||
for _, path in ipairs({ "/etc/llmsproxy/config.yaml", "/usr/local/etc/llmsproxy/config.yaml" }) do
|
||||
local f = io.open(path, "r")
|
||||
if f then
|
||||
local txt = f:read("*a")
|
||||
f:close()
|
||||
if txt then
|
||||
local d = txt:match("\nplugin_dir:%s*([^\n]%S*)") or txt:match("^plugin_dir:%s*([^\n]%S*)")
|
||||
if d and d ~= "" then return d end
|
||||
end
|
||||
end
|
||||
end
|
||||
return "/etc/llmsproxy/plugins"
|
||||
end
|
||||
|
||||
-- persistKeys 是要落盘的字段。故意不含 sessions(登录会话是短命态,重启即失效,
|
||||
-- 写下去只会留下过期垃圾)与 browser(进程 pid 跨重启无意义)。
|
||||
local PERSIST_KEYS = {
|
||||
"accounts", "pool", "modelDisabled", "settings",
|
||||
"providerOrder", "log", "injected_platforms",
|
||||
}
|
||||
|
||||
local function persistState()
|
||||
local dir = storeDir()
|
||||
local path = dir .. "/" .. STORE_NAME
|
||||
local snap = {}
|
||||
for _, k in ipairs(PERSIST_KEYS) do
|
||||
if plugin.state[k] ~= nil then snap[k] = plugin.state[k] end
|
||||
end
|
||||
local body = json.encode({ version = 1, state = snap })
|
||||
if not body then return false, "json.encode 失败" end
|
||||
local tmp = path .. ".tmp"
|
||||
local w = io.open(tmp, "w")
|
||||
if not w then return false, "无法写入 " .. tmp end
|
||||
w:write(body)
|
||||
w:close()
|
||||
os.execute(("chmod 0600 %s 2>/dev/null"):format(tmp))
|
||||
local ok = os.rename(tmp, path)
|
||||
if not ok then
|
||||
os.remove(tmp)
|
||||
return false, "rename 失败"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function applySnapshot(snap)
|
||||
for _, k in ipairs(PERSIST_KEYS) do
|
||||
if snap[k] ~= nil then plugin.state[k] = snap[k] end
|
||||
end
|
||||
end
|
||||
|
||||
-- dropLegacyKernelState 删除内核遗留文件(若存在)。
|
||||
--
|
||||
-- 必须删:内核的 restore() 在插件源码执行**之后**运行,会把这个文件的内容整体
|
||||
-- 覆盖到 plugin.state 上。自有存储已经是权威,留下它等于给"下次启动被旧数据
|
||||
-- 覆盖"留一个后门。删它不需要先读它(迁移路径自己会读)。
|
||||
local function dropLegacyKernelState()
|
||||
local legacy = storeDir() .. "/.channels.state.json"
|
||||
local f = io.open(legacy, "r")
|
||||
if not f then return end
|
||||
f:close()
|
||||
os.remove(legacy)
|
||||
io.stderr:write("[channels] 已删除内核遗留状态文件(自有存储为权威)\n")
|
||||
end
|
||||
|
||||
-- loadState 读插件自有文档;缺失时**从内核遗留文件迁移一次**。
|
||||
--
|
||||
-- 历史背景:v2.0.4 曾让内核的 InvokeHandler 补 markDirty,于是状态被内核写到了
|
||||
-- `.channels.state.json`(内核的 <plugin>.state.json 命名)。现在内核改动已回退,
|
||||
-- 那个文件不会再被内核读写;但生产上已经存在一份(含真实账号池),必须迁移过来,
|
||||
-- 否则升级即丢账号 —— 与本次要修的缺陷同类,不能自己再造一次。
|
||||
-- 迁移后删除遗留文件:留着它只会让「哪个文件权威」变成永久的疑团。
|
||||
local function loadOwnSnapshot()
|
||||
local dir = storeDir()
|
||||
local f = io.open(dir .. "/" .. STORE_NAME, "r")
|
||||
if f then
|
||||
local txt = f:read("*a")
|
||||
f:close()
|
||||
if txt and txt ~= "" then
|
||||
local ok, rec = pcall(json.decode, txt)
|
||||
if ok and type(rec) == "table" and type(rec.state) == "table" then
|
||||
applySnapshot(rec.state)
|
||||
return true
|
||||
end
|
||||
-- 损坏文件不阻塞:用内置默认值(下次写入覆盖它)
|
||||
io.stderr:write("[channels] 自有存储损坏,改用默认值\n")
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- migrateLegacyKernelState 把内核遗留的 .channels.state.json 搬进自有文档。
|
||||
local function migrateLegacyKernelState()
|
||||
local dir = storeDir()
|
||||
local legacy = dir .. "/.channels.state.json"
|
||||
local f = io.open(legacy, "r")
|
||||
if not f then return false end
|
||||
local txt = f:read("*a")
|
||||
f:close()
|
||||
if not txt or txt == "" then return false end
|
||||
local ok, rec = pcall(json.decode, txt)
|
||||
if not ok or type(rec) ~= "table" or type(rec.state) ~= "table" then
|
||||
return false
|
||||
end
|
||||
applySnapshot(rec.state)
|
||||
-- 立即写成自有文档;**只有写成功才删遗留文件** —— 写失败还删它,
|
||||
-- 等于用一次迁移把数据弄丢,与本次要修的缺陷同类。
|
||||
local written, werr = persistState()
|
||||
if written then
|
||||
os.remove(legacy)
|
||||
io.stderr:write("[channels] 已迁移遗留状态到 " .. STORE_NAME .. "\n")
|
||||
else
|
||||
io.stderr:write("[channels] 遗留状态迁移写入失败(保留原文件): "
|
||||
.. tostring(werr) .. "\n")
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function loadState()
|
||||
if loadOwnSnapshot() then
|
||||
-- 自有存储已权威:遗留文件必须删掉,否则内核 restore() 会用它覆盖
|
||||
dropLegacyKernelState()
|
||||
return true
|
||||
end
|
||||
return migrateLegacyKernelState()
|
||||
end
|
||||
|
||||
-- markDirty = 立即同步落盘(tmp + rename 原子替换)。
|
||||
--
|
||||
-- 为什么不用「置标志 + 定时 flush」:本插件没有 hooks,45 个端点各自
|
||||
-- 直接变更状态,还有大量内部调用(syncAccountSource / claim_daily /
|
||||
-- auto_checkin 互调)。把 flush 挂在某个边界上就必须保证**每一条**路径都经过
|
||||
-- 它,漏一条就是「操作成功但重启丢失」——正是本次要修的缺陷形态。同步写在
|
||||
-- 变更点,路径覆盖是结构性的,不依赖我数全 45 个入口。
|
||||
--
|
||||
-- 代价可接受:状态很小(实测 ~1.7KB),channels 没有 chat 钩子,写入只发生在
|
||||
-- 运维动作(登录/领取/开关)与登录轮询(未完成时才有)上,不在请求热路径。
|
||||
-- 参考实现同样是每次变更 writeFileSync + renameSync。
|
||||
--
|
||||
-- 失败处理:写失败**不从状态里回滚**内存变更(内存是对的),只留痕到 stderr
|
||||
-- ——不递归调 logEvent,那会再次触发 markDirty。
|
||||
local function markDirty()
|
||||
local ok, err = persistState()
|
||||
if not ok then
|
||||
io.stderr:write("[channels] 状态落盘失败: " .. tostring(err) .. "\n")
|
||||
end
|
||||
end
|
||||
|
||||
plugin.state = plugin.state or {}
|
||||
plugin.state.accounts = plugin.state.accounts or {}
|
||||
plugin.state.log = plugin.state.log or {}
|
||||
|
||||
-- 启动时从插件自有文档恢复(必须在 plugin.state 建表之后:loadState 往表里填
|
||||
-- 字段,先填再被 or {} 覆盖就白填了)。
|
||||
loadState()
|
||||
|
||||
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
|
||||
markDirty()
|
||||
end
|
||||
|
||||
-- accountOf 取某平台的账号记录(凭据本身在保险库,不在 state 里)
|
||||
@ -854,10 +1032,12 @@ end
|
||||
|
||||
local function poolAdd(provider, entry)
|
||||
poolAccounts(provider)[entry.id] = entry
|
||||
markDirty()
|
||||
end
|
||||
|
||||
local function poolRemove(provider, accountId)
|
||||
poolAccounts(provider)[accountId] = nil
|
||||
markDirty()
|
||||
end
|
||||
|
||||
-- 凭据取值:保险库里存的可能是**裸 token**(历史/device-poll/paste 路径),
|
||||
@ -897,12 +1077,41 @@ end
|
||||
-- CodeBuddy 的 login/account 响应**不含昵称**,昵称只在 access_token 的声明里;
|
||||
-- token 端又只返回 expiresIn(相对秒数)而不含 expiresAt。两处都得从 JWT 兜底。
|
||||
-- 解析失败返回 nil(调用方自行处理)。
|
||||
-- b64url_decode 纯 Lua 的 base64url 解码(读 JWT payload 用)。
|
||||
--
|
||||
-- 为什么不用内核的 base64 能力:内核只提供 encode(适配器签名要用),没有 decode。
|
||||
-- 为读一个 JWT 声明而给内核加一个通用解码函数,是把插件的事推给内核 ——
|
||||
-- 插件能做就该自己做。纯 Lua 实现约 20 行,无依赖、无内核改动。
|
||||
-- 接受标准/URL-safe 两种字母表,容忍缺失填充。
|
||||
local B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
local B64_LOOKUP = {}
|
||||
for i = 1, #B64_ALPHABET do B64_LOOKUP[B64_ALPHABET:sub(i, i)] = i - 1 end
|
||||
local function b64url_decode(input)
|
||||
if type(input) ~= "string" or input == "" then return nil end
|
||||
-- 归一化:URL-safe → 标准,去空白,去掉填充(下面按 4 位一组补零处理)
|
||||
local s = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "")
|
||||
local out, acc, bits = {}, 0, 0
|
||||
for i = 1, #s do
|
||||
local v = B64_LOOKUP[s:sub(i, i)]
|
||||
if v == nil then return nil end -- 非法字符:如实失败,不猜
|
||||
acc = acc * 64 + v
|
||||
bits = bits + 6
|
||||
if bits >= 8 then
|
||||
bits = bits - 8
|
||||
local byte = math.floor(acc / (2 ^ bits)) % 256
|
||||
out[#out + 1] = string.char(byte)
|
||||
acc = acc % (2 ^ bits)
|
||||
end
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
local function jwtClaim(token, name)
|
||||
if type(token) ~= "string" or token == "" then return nil end
|
||||
local payload = token:match("^[^%.]+%.([^%.]+)")
|
||||
if not payload then return nil end
|
||||
local bin, derr = base64_decode(payload) -- 内核新增:base64url 兼容
|
||||
if not bin or derr then return nil end
|
||||
local bin = b64url_decode(payload)
|
||||
if not bin then return nil end
|
||||
local ok, rec = pcall(json.decode, bin)
|
||||
if not ok or type(rec) ~= "table" then return nil end
|
||||
return rec[name]
|
||||
@ -1033,6 +1242,15 @@ local function disabledModelsOf(provider)
|
||||
return m
|
||||
end
|
||||
|
||||
-- setModelDisabled 是**唯一**的模型黑名单写入口:写完即落盘。
|
||||
-- 之所以收成一个函数:黑名单的写点曾有 5+ 处(单个开关/批量/清死模型/供应商
|
||||
-- 整体开关),散着写必然漏标脏。收口后只有这里需要保证。
|
||||
local function setModelDisabled(provider, modelId, disabled)
|
||||
local m = disabledModelsOf(provider)
|
||||
if disabled then m[modelId] = true else m[modelId] = nil end
|
||||
markDirty()
|
||||
end
|
||||
|
||||
-- 渠道积分能力表(对齐参考 CHANNEL_CREDITS):声明各渠道有没有领取/余额。
|
||||
local CHANNEL_CREDITS = {
|
||||
codearts = { claim = true },
|
||||
@ -1136,12 +1354,39 @@ end
|
||||
-- plugin.state.browser.pids,插件卸载或会话结束时 kill。
|
||||
-- 绑定地址默认 127.0.0.1(同机操作);远程运维可把 settings.vnc_bind 设成
|
||||
-- "0.0.0.0" 并依赖 x11vnc 随机会话密码(不设密码的裸 VNC 永远不会出现)。
|
||||
--
|
||||
-- 为什么需要它:有些平台的登录**必须**在服务端完成 —— 回环重定向落不到用户
|
||||
-- 机器上、或授权页要求浏览器内交互(扫码后确认、短信验证码)。这时只有服务端
|
||||
-- 浏览器能走完,VNC 是把那个浏览器呈现给运维的唯一途径。
|
||||
--
|
||||
-- 登录后的取凭据不走「截图 + 人眼看」:浏览器以 --remote-debugging-port 起,
|
||||
-- 完成后经 CDP 读 document.cookie / localStorage / 显式取回的 token,
|
||||
-- 落到保险库。人只负责在 VNC 窗口里完成交互,机器负责把结果取出来。
|
||||
|
||||
local function browserStack()
|
||||
if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end
|
||||
return plugin.state.browser
|
||||
end
|
||||
|
||||
-- browserDeps 探测浏览器栈的外部依赖。返回 { ok=bool, missing={...}, present={...} }。
|
||||
-- 依赖是**外部系统包**(发行版装的),插件不擅自 apt/dnf 安装 —— 那需要 root 且
|
||||
-- 会改动运维的机器;如实报告缺什么,由运维或部署脚本装。deploy.sh 提供安装路径。
|
||||
local BROWSER_DEPS = {
|
||||
{ bin = "Xvfb", pkg = "xvfb", why = "虚拟显示" },
|
||||
{ bin = "x11vnc", pkg = "x11vnc", why = "VNC 服务端" },
|
||||
{ bin = "websockify", pkg = "websockify", why = "VNC→WebSocket 网关" },
|
||||
{ bin = "python3", pkg = "python3", why = "CDP 取凭据" },
|
||||
}
|
||||
local function which(bin)
|
||||
local h = io.popen("command -v " .. bin .. " 2>/dev/null")
|
||||
if not h then return false end
|
||||
local p = h:read("*l")
|
||||
h:close()
|
||||
return p ~= nil and p ~= ""
|
||||
end
|
||||
|
||||
-- reapBrowser 回收本插件拉起的整组进程。进程是插件起的,插件负责杀 ——
|
||||
-- 留一个孤儿 chromium 会一直占着显示与内存,且下次 spawn 会撞端口。
|
||||
local function reapBrowser()
|
||||
local b = browserStack()
|
||||
for _, pid in ipairs(b.pids or {}) do
|
||||
@ -1151,48 +1396,341 @@ local function reapBrowser()
|
||||
b.session = nil
|
||||
end
|
||||
|
||||
-- spawnBrowser 拉起一组服务端浏览器。返回 view_url(给 iframe 内嵌)或 nil, err。
|
||||
-- start_url 是浏览器要打开的登录页;redirect_host 是拼 redirect_uri 用的主机名
|
||||
-- (浏览器在服务端,127.0.0.1 就是服务端自己的回环 —— 回调直接命中网关)。
|
||||
local function browserDeps()
|
||||
local missing, present = {}, {}
|
||||
for _, d in ipairs(BROWSER_DEPS) do
|
||||
if which(d.bin) then present[#present + 1] = d.bin
|
||||
else missing[#missing + 1] = { bin = d.bin, pkg = d.pkg, why = d.why } end
|
||||
end
|
||||
-- chromium 的二进制名随发行版变化(chromium / chromium-browser / google-chrome)
|
||||
local chromium = nil
|
||||
for _, c in ipairs({ "chromium", "chromium-browser", "google-chrome", "google-chrome-stable" }) do
|
||||
if which(c) then chromium = c break end
|
||||
end
|
||||
if chromium then present[#present + 1] = chromium
|
||||
else missing[#missing + 1] = { bin = "chromium", pkg = "chromium", why = "浏览器" } end
|
||||
-- noVNC 静态资源(websockify --web 指向它)
|
||||
local novnc = nil
|
||||
for _, p in ipairs({ "/usr/share/novnc", "/usr/share/webapps/novnc", "/opt/novnc" }) do
|
||||
local f = io.open(p .. "/vnc.html", "r")
|
||||
if f then f:close() novnc = p break end
|
||||
end
|
||||
if not novnc then
|
||||
missing[#missing + 1] = { bin = "novnc", pkg = "novnc", why = "浏览器内嵌页面" }
|
||||
end
|
||||
-- CDP 需要 websocket 客户端
|
||||
local pyws = false
|
||||
if which("python3") then
|
||||
local h = io.popen("python3 -c 'import websocket' 2>/dev/null && echo yes")
|
||||
if h then local r = h:read("*l") h:close() pyws = (r == "yes") end
|
||||
end
|
||||
if which("python3") and not pyws then
|
||||
missing[#missing + 1] = { bin = "python3-websocket", pkg = "python3-websocket", why = "CDP 取凭据(websocket-client)" } end
|
||||
return {
|
||||
ok = #missing == 0,
|
||||
missing = missing,
|
||||
present = present,
|
||||
chromium = chromium,
|
||||
novnc = novnc,
|
||||
}
|
||||
end
|
||||
|
||||
-- gatewayPort 问内核要本网关的监听端口。VNC 里的浏览器要回调到本插件的挂载点,
|
||||
-- 端口不能硬编码(非默认部署会全错)。内核 status 里带 listen(如 "0.0.0.0:8081")。
|
||||
-- gatewayPort 求本网关的监听端口。VNC 里的浏览器要回调到本插件的挂载点,
|
||||
-- 端口不能硬编码(非默认部署会全错)。
|
||||
--
|
||||
-- 端口由插件**自己**取得,不问内核:插件在信任模型里就是可信、有完整 OS 能力的
|
||||
-- (能拉浏览器栈、能在磁盘上读写),读一下自己的 config.yaml 是它能力范围内的事。
|
||||
-- 为了一个端口给内核加只读动作,既不必要,也污染了内核的「零厂商/零部署知识」边界。
|
||||
-- 优先级:settings 显式覆盖 > config.yaml 的 listen > 环境变量 > 默认值。
|
||||
local function gatewayPort()
|
||||
-- ① settings:运维可显式指定(前置代理 / 反代场景,对外端口与 listen 不同)
|
||||
local s = plugin.state.settings and plugin.state.settings.gateway_port
|
||||
if tonumber(s) then return tonumber(s) end
|
||||
-- ② 配置文件:插件与内核同机,直接读自己的配置
|
||||
for _, path in ipairs({
|
||||
"/etc/llmsproxy/config.yaml",
|
||||
"/usr/local/etc/llmsproxy/config.yaml",
|
||||
}) do
|
||||
local f = io.open(path, "r")
|
||||
if f then
|
||||
local listen = f:read("*a")
|
||||
f:close()
|
||||
if listen then
|
||||
local addr = listen:match("\nlisten:%s*([^\n]%S*)") or listen:match("^listen:%s*([^\n]%S*)")
|
||||
if addr then
|
||||
local p = addr:match(":(%d+)")
|
||||
if p then return tonumber(p) end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- ③ 实听端口:从内核自己的监听 socket 反查(配置文件缺失/被换时兜底)
|
||||
do
|
||||
local h = io.popen("ss -ltnp 2>/dev/null | grep 'pid=' | grep -o ':[0-9]*' | head -1")
|
||||
if h then
|
||||
local line = h:read("*l")
|
||||
h:close()
|
||||
local p = line and line:match(":(%d+)")
|
||||
if p then return tonumber(p) end
|
||||
end
|
||||
end
|
||||
return 8081
|
||||
end
|
||||
|
||||
-- waitPort 等端口就绪(就绪探针),避免「进程起来了但还不能连」。
|
||||
-- 返回 true/false,最多等 timeout_ms。
|
||||
local function waitPort(host, port, timeoutMs)
|
||||
local deadline = (os.time and os.time() or 0) * 1000 + timeoutMs
|
||||
while true do
|
||||
local h = io.popen(("bash -c 'exec 3<>/dev/tcp/%s/%d' 2>/dev/null && echo ok"):format(host, port))
|
||||
local r = h and h:read("*l")
|
||||
if h then h:close() end
|
||||
if r == "ok" then return true end
|
||||
local now = (os.time and os.time() or 0) * 1000
|
||||
if now >= deadline then return false end
|
||||
os.execute("sleep 0.3")
|
||||
end
|
||||
end
|
||||
|
||||
-- CDP 取凭据的 Python 桥。argv: <script> <cdp_port> <expr_file>
|
||||
-- 打印一行 JSON: {"ok":true,"value":<...>} 或 {"ok":false,"error":"..."}
|
||||
-- 用文件传表达式,避免 shell 引号地狱(表达式里常有引号/换行)。
|
||||
local CDP_PY = [[
|
||||
import json, sys, urllib.request
|
||||
try:
|
||||
import websocket
|
||||
except Exception as e:
|
||||
print(json.dumps({"ok": False, "error": "websocket-client 未安装: %s" % e}))
|
||||
sys.exit(0)
|
||||
|
||||
port = int(sys.argv[1])
|
||||
with open(sys.argv[2]) as f:
|
||||
expr = f.read()
|
||||
|
||||
def http(path):
|
||||
with urllib.request.urlopen("http://127.0.0.1:%d%s" % (port, path), timeout=10) as r:
|
||||
return json.load(r)
|
||||
|
||||
try:
|
||||
targets = http("/json/list")
|
||||
except Exception as e:
|
||||
print(json.dumps({"ok": False, "error": "CDP 不可达: %s" % e})); sys.exit(0)
|
||||
|
||||
page = None
|
||||
for t in targets:
|
||||
if t.get("type") == "page" and t.get("webSocketDebuggerUrl"):
|
||||
page = t; break
|
||||
if page is None:
|
||||
print(json.dumps({"ok": False, "error": "没有可用的 page target"})); sys.exit(0)
|
||||
|
||||
try:
|
||||
ws = websocket.create_connection(page["webSocketDebuggerUrl"], timeout=20)
|
||||
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
|
||||
"params": {"expression": expr, "returnByValue": True,
|
||||
"awaitPromise": True}}))
|
||||
while True:
|
||||
msg = json.loads(ws.recv())
|
||||
if msg.get("id") == 1:
|
||||
break
|
||||
ws.close()
|
||||
except Exception as e:
|
||||
print(json.dumps({"ok": False, "error": "CDP 求值失败: %s" % e})); sys.exit(0)
|
||||
|
||||
res = msg.get("result", {})
|
||||
if "exceptionDetails" in res:
|
||||
print(json.dumps({"ok": False, "error": res["exceptionDetails"].get("text", "JS 异常")}))
|
||||
sys.exit(0)
|
||||
print(json.dumps({"ok": True, "value": res.get("result", {}).get("value")}))
|
||||
]]
|
||||
|
||||
local function cdpScriptPath()
|
||||
local p = "/tmp/llmsproxy-cdp-eval.py"
|
||||
local f = io.open(p, "r")
|
||||
if f then f:close() return p end
|
||||
local w = io.open(p, "w")
|
||||
if not w then return nil end
|
||||
w:write(CDP_PY)
|
||||
w:close()
|
||||
return p
|
||||
end
|
||||
|
||||
-- cdpEval 在服务端浏览器的当前页上求值一个 JS 表达式。
|
||||
-- 返回 (value, err)。value 是 JS 返回值(经 returnByValue 序列化)。
|
||||
local function cdpEval(cdpPort, expr)
|
||||
local script = cdpScriptPath()
|
||||
if not script then return nil, "无法写入 CDP 脚本" end
|
||||
local exprFile = ("/tmp/llmsproxy-cdp-expr-%d.js"):format(cdpPort)
|
||||
local w = io.open(exprFile, "w")
|
||||
if not w then return nil, "无法写入表达式文件" end
|
||||
w:write(expr)
|
||||
w:close()
|
||||
local h = io.popen(("python3 %s %d %s 2>/dev/null"):format(script, cdpPort, exprFile))
|
||||
if not h then os.remove(exprFile) return nil, "python3 执行失败" end
|
||||
local out = h:read("*a")
|
||||
h:close()
|
||||
os.remove(exprFile)
|
||||
if not out or out == "" then return nil, "CDP 无输出" end
|
||||
local ok, rec = pcall(json.decode, out)
|
||||
if not ok or type(rec) ~= "table" then return nil, "CDP 输出无法解析: " .. tostring(out):sub(1, 200) end
|
||||
if rec.ok ~= true then return nil, tostring(rec.error or "CDP 失败") end
|
||||
return rec.value, nil
|
||||
end
|
||||
|
||||
-- spawnBrowser 拉起一组服务端浏览器。返回
|
||||
-- { view_url, password, cdp_port, display, vnc, ws } 或 nil, err。
|
||||
-- start_url 是浏览器要打开的登录页;callback_path 是拼 redirect_uri 用的路径。
|
||||
--
|
||||
-- 与早期实现的差别(都是真实踩过的坑):
|
||||
-- 1. **就绪探针**:不再 sleep 2 拍脑袋,各端口逐个等(X 起来 → 浏览器起 →
|
||||
-- VNC 起 → websockify 起),否则 view_url 先于服务可用,iframe 白屏。
|
||||
-- 2. **CDP 端口**:浏览器带 --remote-debugging-port,登录完成后可读凭据
|
||||
-- (见 cdpEval / vncCollect),而不是只能人眼看屏幕。
|
||||
-- 3. **网关端口来自内核**:回调地址拼的是本实例真实端口,不硬编码 8081。
|
||||
-- 4. **依赖预检**:缺依赖直接给出清单(谁、干什么用、装什么包),
|
||||
-- 而不是一个含糊的"进程启动失败"。
|
||||
local function spawnBrowser(start_url, callback_path)
|
||||
local b = browserStack()
|
||||
if b.session then return nil, "已有浏览器会话在跑,请先停止" end
|
||||
local deps = browserDeps()
|
||||
if not deps.ok then
|
||||
local miss = {}
|
||||
for _, m in ipairs(deps.missing) do
|
||||
miss[#miss + 1] = ("%s(%s,装 %s)"):format(m.bin, m.why, m.pkg)
|
||||
end
|
||||
return nil, "缺少浏览器栈依赖: " .. table.concat(miss, ", ")
|
||||
end
|
||||
b.seq = b.seq + 1
|
||||
local disp = 90 + (b.seq % 10)
|
||||
local vport = 59100 + (b.seq % 100)
|
||||
local wport = 60100 + (b.seq % 100)
|
||||
local seq = b.seq
|
||||
-- display / 端口取号:**探测空闲**,不能按 seq 取模。
|
||||
--
|
||||
-- 真实缺陷:原先写 `90 + (seq % 10)`,而 Xvfb 的锁文件(/tmp/.X<n>-lock)在
|
||||
-- 进程被强杀时会残留。重启后 seq 从头开始 → 又选中同一个号 → Xvfb 直接
|
||||
-- `Fatal: Server is already active for display 91` 退出,VNC 兜底永远起不来,
|
||||
-- 而报错只显示"Xvfb 未就绪",看不出是被残留锁挡住。
|
||||
-- 现在从 91 起找第一个既无锁文件、也无 socket 的号;端口同理探测空闲。
|
||||
local function pickFree(start, isFree)
|
||||
for n = start, start + 40 do
|
||||
if isFree(n) then return n end
|
||||
end
|
||||
return start
|
||||
end
|
||||
-- 判存在必须用 `[ -e ]` 而不是 io.open:X socket 是 socket 文件,
|
||||
-- io.open 对它返回 nil(打不开)→ 探测会误判"空闲"→ 又选中被残留 socket
|
||||
-- 占住的号 → Xvfb `Cannot establish any listening sockets` 退出。
|
||||
-- 这个坑真实踩过:残留 X91 socket 让显示器探测完全失效。
|
||||
local function pathExists(path)
|
||||
local ok = os.execute('[ -e "' .. path .. '" ] 2>/dev/null')
|
||||
-- Lua 5.1 返回数字(0=成功),5.2+ 返回 true/nil
|
||||
return ok == true or ok == 0
|
||||
end
|
||||
local disp = pickFree(91, function(n)
|
||||
return not pathExists("/tmp/.X" .. n .. "-lock")
|
||||
and not pathExists("/tmp/.X11-unix/X" .. n)
|
||||
end)
|
||||
local function portFree(p)
|
||||
-- 用 /proc/net/tcp + tcp6 查是否有 LISTEN(十六进制端口)
|
||||
for _, f in ipairs({ "/proc/net/tcp", "/proc/net/tcp6" }) do
|
||||
local h = io.open(f, "r")
|
||||
if h then
|
||||
local hex = ("%04X"):format(p)
|
||||
for line in h:lines() do
|
||||
local lp = line:match("^%s*%d+:%s*[0-9A-F]+:([0-9A-F]+)")
|
||||
if lp == hex then h:close() return false end
|
||||
end
|
||||
h:close()
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
local vport = pickFree(59100, portFree)
|
||||
local wport = pickFree(60100, portFree)
|
||||
local cdpPort = pickFree(61100, portFree)
|
||||
local bind = (plugin.state.settings and plugin.state.settings.vnc_bind) or "127.0.0.1"
|
||||
-- 会话密码:bind 到非回环时是唯一的防线,随机生成、只在响应里给一次
|
||||
local pass = sha256_hex(newToken()):sub(1, 16)
|
||||
-- 端口占位换成网关回环(浏览器在服务端,回调直接命中网关挂载点)
|
||||
local url = start_url:gsub("redirect_uri=127%.0%.0%.1:%%d+", "redirect_uri=127.0.0.1:8081" .. callback_path)
|
||||
local gwPort = gatewayPort()
|
||||
-- 端口占位换成网关真实回环地址(浏览器在服务端,回调直接命中网关挂载点)
|
||||
local url = start_url:gsub("redirect_uri=127%.0%.0%.1:%%d+",
|
||||
"redirect_uri=127.0.0.1:" .. gwPort .. callback_path)
|
||||
|
||||
local cmds = {
|
||||
("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp & echo $!"):format(disp),
|
||||
("chromium --no-sandbox --disable-dev-shm-usage --user-data-dir=/tmp/llmsproxy-ch-%d --no-first-run --window-size=1280,800 --display=:%d '%s' & echo $!"):format(b.seq, disp, url),
|
||||
("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet & echo $!"):format(disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass),
|
||||
("websockify --web /usr/share/novnc %s:%d 127.0.0.1:%d & echo $!"):format(bind, wport, vport),
|
||||
}
|
||||
-- 每步起一个进程并等它的端口就绪(X 用 socket 探,别的用 TCP)。
|
||||
local pids = {}
|
||||
for _, c in ipairs(cmds) do
|
||||
local h = io.popen(c)
|
||||
local pid = h:read("*l")
|
||||
h:close()
|
||||
local function spawn(name, cmd, host, port, timeoutMs)
|
||||
local h = io.popen(cmd)
|
||||
local pid = h and h:read("*l")
|
||||
if h then h:close() end
|
||||
if not pid or pid == "" then
|
||||
reapBrowser()
|
||||
return nil, "进程启动失败: " .. c:match("^(%S+)") .. "(缺依赖?运行 deploy 脚本一键安装)"
|
||||
return false, name .. " 启动失败(cmd=" .. tostring(cmd):sub(1,80) .. ")"
|
||||
end
|
||||
pids[#pids+1] = pid
|
||||
pids[#pids + 1] = pid
|
||||
-- 立刻回写到 plugin.state,**不能只在函数末尾赋值**:
|
||||
-- 任何一步中途失败都要 reapBrowser(),而它读的是 b.pids。若此时 b.pids 还是
|
||||
-- 旧值(空),已经起来的进程就不会被杀 → 孤儿 Xvfb/chromium 常驻。
|
||||
-- 真实踩过:4 个失败的 VNC 会话留下 4 个 orphan Xvfb。
|
||||
b.pids = pids
|
||||
if port and port > 0 then
|
||||
if not waitPort(host or "127.0.0.1", port, timeoutMs or 8000) then
|
||||
return false, name .. " 端口未就绪(:" .. port .. ")"
|
||||
end
|
||||
end
|
||||
return true, pid
|
||||
end
|
||||
-- 给 X/chromium 一点起进程时间;不等待就绪探针(保持插件逻辑简单,失败可重试)
|
||||
os.execute("sleep 2")
|
||||
|
||||
local chromium = deps.chromium
|
||||
local novnc = deps.novnc
|
||||
local steps = {
|
||||
{ "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/llmsproxy-xvfb-%d.log 2>&1 & echo $!"):format(disp, seq), nil, 0, 0 },
|
||||
{ "chromium", ("%s --no-sandbox --disable-dev-shm-usage --user-data-dir=/tmp/llmsproxy-ch-%d "
|
||||
.. "--no-first-run --disable-gpu --remote-debugging-port=%d --remote-allow-origins=* "
|
||||
.. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%d.log 2>&1 & echo $!"):format(
|
||||
chromium, seq, cdpPort, disp, url, seq), "127.0.0.1", cdpPort, 15000 },
|
||||
{ "x11vnc", ("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet "
|
||||
.. ">/tmp/llmsproxy-x11vnc-%d.log 2>&1 & echo $!"):format(
|
||||
disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass, seq), "127.0.0.1", vport, 8000 },
|
||||
{ "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/llmsproxy-ws-%d.log 2>&1 & echo $!"):format(
|
||||
novnc, bind, wport, vport, seq), bind, wport, 8000 },
|
||||
}
|
||||
-- ① 先起 Xvfb(其余三步依赖 X display)
|
||||
local xvfbStep = steps[1]
|
||||
local xok, xerr = spawn(xvfbStep[1], xvfbStep[2], nil, 0, 0)
|
||||
if not xok then
|
||||
reapBrowser()
|
||||
return nil, xerr
|
||||
end
|
||||
-- X 的 socket 在 /tmp/.X11-unix/X<n>,等它出现再起浏览器
|
||||
local unixSock = ("/tmp/.X11-unix/X%d"):format(disp)
|
||||
local xdeadline = (os.time and os.time() or 0) + 10
|
||||
while true do
|
||||
-- 同 pathExists 的理由:X socket 是 socket 文件,io.open 打不开,
|
||||
-- 用它判"就绪"会永远等不到(这个坑在上面选号处也踩过一次)。
|
||||
if pathExists(unixSock) then break end
|
||||
if (os.time and os.time() or 0) >= xdeadline then
|
||||
reapBrowser()
|
||||
return nil, "Xvfb 未就绪(" .. unixSock .. " 未出现)"
|
||||
end
|
||||
os.execute("sleep 0.3")
|
||||
end
|
||||
-- ② 起浏览器/VNC/websockify(三步都有 TCP 端口可探)
|
||||
for i = 2, #steps do
|
||||
local st = steps[i]
|
||||
local ok, err = spawn(st[1], st[2], st[3], st[4], st[5])
|
||||
if not ok then
|
||||
reapBrowser()
|
||||
return nil, err
|
||||
end
|
||||
end
|
||||
|
||||
b.pids = pids
|
||||
b.session = { display = disp, vnc = vport, ws = wport, pass = pass }
|
||||
-- view_url 指向 websockify 伺服的 noVNC;同机直接 127.0.0.1,远程用 settings.vnc_host 覆盖
|
||||
b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq }
|
||||
local host = (plugin.state.settings and plugin.state.settings.vnc_host) or "127.0.0.1"
|
||||
return {
|
||||
view_url = ("http://%s:%d/vnc.html?autoconnect=1&host=%s&port=%d&password=%s"):format(host, wport, host, wport, pass),
|
||||
view_url = ("http://%s:%d/vnc.html?autoconnect=1&host=%s&port=%d&password=%s"):format(
|
||||
host, wport, host, wport, pass),
|
||||
password = pass,
|
||||
cdp_port = cdpPort,
|
||||
display = disp,
|
||||
}
|
||||
end
|
||||
|
||||
@ -1271,11 +1809,15 @@ function plugin.start_login(req)
|
||||
local bs, berr = spawnBrowser(string.format(plat.login_url, 0), "/api/plugins/http/channels/callback")
|
||||
if berr then
|
||||
out.browser_error = berr
|
||||
out.hint = "服务端浏览器不可用;先装依赖(见插件一键初始化),或改用其他登录流程"
|
||||
out.hint = "服务端浏览器不可用;运行 channels/deps/status 看缺什么依赖"
|
||||
logEvent("服务端浏览器启动失败: " .. berr)
|
||||
else
|
||||
out.view_url = bs.view_url
|
||||
logEvent("服务端浏览器已就绪 " .. plat.id)
|
||||
out.session.cdp_port = bs.cdp_port
|
||||
sess.cdp_port = bs.cdp_port
|
||||
-- 登录页需要从服务端浏览器完成;用户交互后由 collect_credentials 取凭据
|
||||
sess.collect_expr = plat.collect_expr
|
||||
logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")")
|
||||
end
|
||||
elseif plat.flow == "device-poll" then
|
||||
-- 设备码:申请码,把 user_code + 授权 URL 给用户,verifier 存进会话。
|
||||
@ -1724,6 +2266,15 @@ plugin.http = {
|
||||
roles = { "admin" }, description = "领取新人奖励" },
|
||||
{ path = "channels/inject_platforms", method = "POST", handler = "inject_platforms",
|
||||
roles = { "admin" }, description = "注入测试/扩展平台定义(运行时,不落盘)" },
|
||||
-- 服务端浏览器栈(VNC 兜底)的运维面
|
||||
{ path = "channels/deps/status", method = "GET", handler = "deps_status",
|
||||
roles = { "admin" }, description = "浏览器栈依赖预检(缺什么、装哪个包)" },
|
||||
{ path = "channels/browser/status", method = "GET", handler = "browser_status",
|
||||
roles = { "admin" }, description = "服务端浏览器会话状态" },
|
||||
{ path = "channels/browser/collect", method = "POST", handler = "collect_credentials",
|
||||
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
|
||||
{ path = "channels/browser/stop", method = "POST", handler = "stop_browser",
|
||||
roles = { "admin" }, description = "回收服务端浏览器栈" },
|
||||
}
|
||||
|
||||
-- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、
|
||||
@ -1897,11 +2448,11 @@ function plugin.model_set_disabled(req)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local mid = tostring(args and args.model_id or "")
|
||||
if mid == "" then return { status = 400, json = { error = "缺 model_id" } } end
|
||||
local disabled = disabledModelsOf(plat.id)
|
||||
if args.disabled then disabled[mid] = true else disabled[mid] = nil end
|
||||
local want = args.disabled and true or false
|
||||
setModelDisabled(plat.id, mid, want)
|
||||
-- 重同步该平台的全部源,让模型开关立即生效
|
||||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||||
return { status = 200, json = { ok = true, disabled = disabled[mid] and true or false } }
|
||||
return { status = 200, json = { ok = true, disabled = want } }
|
||||
end
|
||||
|
||||
function plugin.model_set_all_disabled(req)
|
||||
@ -1915,6 +2466,7 @@ function plugin.model_set_all_disabled(req)
|
||||
else
|
||||
for k in pairs(disabled) do disabled[k] = nil end
|
||||
end
|
||||
markDirty()
|
||||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
@ -1953,6 +2505,7 @@ function plugin.provider_set_enabled(req)
|
||||
local disabled = disabledModelsOf(plat.id)
|
||||
if enabled then
|
||||
for k in pairs(disabled) do disabled[k] = nil end
|
||||
markDirty()
|
||||
for _, a in ipairs(poolList(plat.id)) do a.enabled = true poolAdd(plat.id, a) syncAccountSource(plat.id, a.id) end
|
||||
-- 旧路径也启用
|
||||
local legacy = plugin.state.accounts[plat.id]
|
||||
@ -2057,6 +2610,7 @@ function plugin.backup_import(req)
|
||||
-- 恢复模型黑名单
|
||||
if type(payload.modelDisabled) == "table" then
|
||||
plugin.state.modelDisabled = payload.modelDisabled
|
||||
markDirty()
|
||||
end
|
||||
-- 恢复自动签到开关
|
||||
if payload.autoCheckin ~= nil then
|
||||
@ -2077,6 +2631,7 @@ function plugin.auto_checkin(req)
|
||||
local args = bodyOf(req)
|
||||
if args.enabled ~= nil then
|
||||
plugin.state.settings.auto_checkin = args.enabled and true or false
|
||||
markDirty()
|
||||
logEvent("自动签到" .. (plugin.state.settings.auto_checkin and "开启" or "关闭"))
|
||||
end
|
||||
return { status = 200, json = { ok = true, autoCheckin = plugin.state.settings.auto_checkin ~= false } }
|
||||
@ -2187,6 +2742,7 @@ function plugin.credits_permanent_lock(req)
|
||||
return { status = 400, json = { error = "locked 必须是布尔值" } }
|
||||
end
|
||||
plugin.state.settings["lock_" .. plat.id] = args.locked
|
||||
markDirty()
|
||||
logEvent("永久积分" .. (args.locked and "锁定 " or "解除 ") .. plat.id)
|
||||
end
|
||||
return { status = 200, json = { ok = true, locked = plugin.state.settings["lock_" .. plat.id] == true,
|
||||
@ -2240,6 +2796,7 @@ function plugin.model_set_disabled_many(req)
|
||||
for _, mid in ipairs(ids) do
|
||||
if args.disabled then disabled[tostring(mid)] = true else disabled[tostring(mid)] = nil end
|
||||
end
|
||||
markDirty()
|
||||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
@ -2257,6 +2814,7 @@ function plugin.model_clear_dead(req)
|
||||
for _, m in ipairs(plat.models or {}) do if m == mid then alive = true break end end
|
||||
if not alive then disabled[mid] = nil removed[#removed+1] = mid end
|
||||
end
|
||||
markDirty()
|
||||
for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end
|
||||
return { status = 200, json = { ok = true, removed = removed } }
|
||||
end
|
||||
@ -2277,6 +2835,7 @@ function plugin.provider_set_order(req)
|
||||
if platformById(tostring(id)) then cleaned[#cleaned+1] = tostring(id) end
|
||||
end
|
||||
plugin.state.providerOrder = cleaned
|
||||
markDirty()
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
|
||||
@ -2525,6 +3084,121 @@ end
|
||||
-- “这就是本轮我要用的目录”,避免测试之间残留状态。传空数组即清空。
|
||||
-- 容量上限:注入属于运维/测试能力,不承担生产目录职责,限 32 条够用。
|
||||
local INJECT_PLATFORM_LIMIT = 32
|
||||
-- ============================================================================
|
||||
-- 服务端浏览器栈的运维面(依赖预检 / 取凭据 / 停栈)
|
||||
-- ============================================================================
|
||||
|
||||
-- deps_status:如实报告浏览器栈缺什么依赖。插件**不擅自**安装系统包(需要 root
|
||||
-- 且会改动运维的机器);如实报告 + 给出安装命令,由运维或 deploy.sh 执行。
|
||||
function plugin.deps_status(req)
|
||||
local d = browserDeps()
|
||||
local list = {}
|
||||
for _, m in ipairs(d.missing) do
|
||||
list[#list + 1] = { bin = m.bin, pkg = m.pkg, why = m.why }
|
||||
end
|
||||
return { status = 200, json = {
|
||||
ok = d.ok,
|
||||
missing = list,
|
||||
present = d.present,
|
||||
chromium = d.chromium,
|
||||
novnc = d.novnc,
|
||||
-- 安装入口:发行包自带脚本(识别 apt/dnf/yum/apk),插件**不执行**它
|
||||
-- (装系统包需要 root,且 chromium 是安全面较大的组件,不该由插件代劳)。
|
||||
install_hint = d.ok and "" or
|
||||
"sudo bash /usr/share/llmsproxy/install-browser-stack.sh"
|
||||
.. " 或沿源码部署时: sudo bash packaging/install-browser-stack.sh"
|
||||
.. "(脚本会自动识别 apt/dnf/yum/apk;也可手工装 Xvfb x11vnc websockify noVNC chromium python3-websocket)",
|
||||
} }
|
||||
end
|
||||
|
||||
-- collect_credentials:从**运行中的**服务端浏览器取凭据(CDP)。
|
||||
--
|
||||
-- 为什么需要它:VNC 只把浏览器呈现给人,登录完成后必须把结果**取出来**才能
|
||||
-- 变成上游源。取法分两种:
|
||||
-- ① 平台声明了 collect_expr —— 在该页上求值该 JS(返回 token 字符串或对象)
|
||||
-- ② 未声明 —— 回退到通用提取:localStorage / sessionStorage / cookie 全量
|
||||
-- 查到什么存什么,由调用方/运维判断哪个是凭据(不猜语义,不编造)。
|
||||
-- 取到的凭据经 complete_login 走正常入库路径(含 account_id → 池路径)。
|
||||
function plugin.collect_credentials(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 b = browserStack()
|
||||
if not b.session then return { status = 400, json = { error = "没有运行中的浏览器会话" } } end
|
||||
local cdp = b.session.cdp
|
||||
if not cdp then return { status = 400, json = { error = "浏览器会话未启用 CDP" } } end
|
||||
|
||||
local expr = (args and args.expr) or plat.collect_expr
|
||||
local value, err
|
||||
if expr then
|
||||
value, err = cdpEval(cdp, expr)
|
||||
if err then return { status = 400, json = { error = "取值失败: " .. err } } end
|
||||
end
|
||||
|
||||
-- 通用提取:无论有无 collect_expr 都跑一遍,作为兜底与补充。
|
||||
local generic, gerr = cdpEval(cdp, [[
|
||||
(function(){
|
||||
var out = {localStorage:{}, sessionStorage:{}, cookie:document.cookie, url:location.href};
|
||||
try { for (var i=0;i<localStorage.length;i++){var k=localStorage.key(i);out.localStorage[k]=localStorage.getItem(k);} } catch(e){}
|
||||
try { for (var i=0;i<sessionStorage.length;i++){var k=sessionStorage.key(i);out.sessionStorage[k]=sessionStorage.getItem(k);} } catch(e){}
|
||||
return JSON.stringify(out);
|
||||
})()
|
||||
]])
|
||||
|
||||
local rec = {}
|
||||
if value ~= nil then
|
||||
if type(value) == "table" then
|
||||
for k, v in pairs(value) do rec[k] = v end
|
||||
else
|
||||
rec.collected = value
|
||||
end
|
||||
end
|
||||
if generic then
|
||||
local ok, parsed = pcall(json.decode, generic)
|
||||
if ok and type(parsed) == "table" then
|
||||
rec.browser = parsed
|
||||
-- 常见位置的启发式提取(找到就用,找不到不强求)
|
||||
local ls = parsed.localStorage or {}
|
||||
for _, k in ipairs({ "token", "access_token", "accessToken", "auth_token", "jwt", "id_token", "Authorization" }) do
|
||||
if type(ls[k]) == "string" and ls[k] ~= "" and rec.token == nil then rec.token = ls[k] end
|
||||
end
|
||||
local ss = parsed.sessionStorage or {}
|
||||
for _, k in ipairs({ "token", "access_token", "accessToken", "auth_token", "jwt", "id_token" }) do
|
||||
if type(ss[k]) == "string" and ss[k] ~= "" and rec.token == nil then rec.token = ss[k] end
|
||||
end
|
||||
if rec.token == nil and type(parsed.cookie) == "string" then
|
||||
rec.token = parsed.cookie:match("(access_token=[^;]+)")
|
||||
or parsed.cookie:match("(token=[^;]+)")
|
||||
end
|
||||
end
|
||||
elseif gerr then
|
||||
rec.generic_error = gerr
|
||||
end
|
||||
|
||||
if next(rec) == nil then
|
||||
return { status = 200, json = { ok = false, error = "没有取到任何凭据(登录是否真的完成了?)" } }
|
||||
end
|
||||
-- 不自动入库:取到什么原样返回,由调用方确认后再 complete_login。
|
||||
-- 自动写入会把"页面上恰好存在的某个 token"当成凭据,那是最糟的猜测。
|
||||
return { status = 200, json = { ok = true, collected = rec } }
|
||||
end
|
||||
|
||||
-- stop_browser:回收服务端浏览器栈(会话结束/用户放弃时)。
|
||||
function plugin.stop_browser(req)
|
||||
local had = browserStack().session ~= nil
|
||||
reapBrowser()
|
||||
if had then logEvent("服务端浏览器已回收") end
|
||||
return { status = 200, json = { ok = true, stopped = had } }
|
||||
end
|
||||
|
||||
-- browser_status:当前浏览器会话状态(给 UI 显示)。
|
||||
function plugin.browser_status(req)
|
||||
local b = browserStack()
|
||||
local s = b.session
|
||||
return { status = 200, json = { ok = true, running = s ~= nil, session = s } }
|
||||
end
|
||||
|
||||
function plugin.inject_platforms(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platforms == nil then args = req end
|
||||
@ -2553,6 +3227,7 @@ function plugin.inject_platforms(req)
|
||||
clean[#clean + 1] = p
|
||||
end
|
||||
plugin.state.injected_platforms = clean
|
||||
markDirty()
|
||||
if #clean == 0 then
|
||||
logEvent("已清空注入平台")
|
||||
else
|
||||
|
||||
@ -1358,38 +1358,6 @@ func setupGlobals(L *golua.State) {
|
||||
L.PushString(base64.StdEncoding.EncodeToString([]byte(L.ToString(1))))
|
||||
return 1
|
||||
})
|
||||
// base64_decode accepts standard, URL-safe and unpadded alphabets. It exists
|
||||
// because a plugin reading a JWT payload (e.g. to read `exp`/`nickname` when
|
||||
// the token endpoint omits them) must decode base64url, and hardcoding that
|
||||
// in Lua would mean reimplementing base64 in a plugin. Generic, no vendor
|
||||
// knowledge: an invalid string yields (nil, err), never a partial result.
|
||||
registerFn(L, "base64_decode", func(L *golua.State) int {
|
||||
s := strings.TrimSpace(L.ToString(1))
|
||||
if s == "" {
|
||||
L.PushString("")
|
||||
L.PushNil()
|
||||
return 2
|
||||
}
|
||||
// Normalise URL-safe alphabet and re-pad to a multiple of 4.
|
||||
s = strings.NewReplacer("-", "+", "_", "/").Replace(s)
|
||||
if pad := len(s) % 4; pad != 0 {
|
||||
if pad == 1 {
|
||||
L.PushNil()
|
||||
L.PushString("invalid base64 length")
|
||||
return 2
|
||||
}
|
||||
s += strings.Repeat("=", 4-pad)
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
L.PushNil()
|
||||
L.PushString(err.Error())
|
||||
return 2
|
||||
}
|
||||
L.PushString(string(b))
|
||||
L.PushNil()
|
||||
return 2
|
||||
})
|
||||
registerFn(L, "tohex", func(L *golua.State) int {
|
||||
L.PushString(hex.EncodeToString([]byte(L.ToString(1))))
|
||||
return 1
|
||||
|
||||
123
packaging/install-browser-stack.sh
Executable file
123
packaging/install-browser-stack.sh
Executable file
@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# 安装「服务端浏览器栈」依赖(VNC 兜底登录用)
|
||||
#
|
||||
# 为什么是独立脚本而不是 postinst 自动装:
|
||||
# 这些是**系统级包**(Xvfb / x11vnc / websockify / noVNC / chromium),装上
|
||||
# 会常驻数百 MB,且 chromium 是安全面较大的组件。很多部署根本用不到 VNC 兜底
|
||||
# (大部分平台的登录是 device-poll / callback / paste,纯 HTTP 即可)。默认替
|
||||
# 运维的机器装一堆浏览器组件不是发行包该做的事 —— 需要的人显式装。
|
||||
#
|
||||
# 什么时候需要它:
|
||||
# 平台声明 flow="vnc"(登录必须在服务端浏览器里完成:回环回调落不到用户机器、
|
||||
# 授权页要求浏览器内交互)。插件会在登录时如实报告缺哪个依赖。
|
||||
#
|
||||
# 用法:
|
||||
# sudo bash packaging/install-browser-stack.sh # 自动识别包管理器
|
||||
# sudo bash packaging/install-browser-stack.sh --check # 只检查,不安装
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
CHECK_ONLY=0
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--check|-c) CHECK_ONLY=1 ;;
|
||||
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||
*) echo "未知参数: $a" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 需要的外部可执行文件(插件 browserDeps 同一份清单)
|
||||
BINS=(Xvfb x11vnc websockify python3)
|
||||
# chromium 的二进制名随发行版变化
|
||||
CHROMIUM_CANDIDATES=(chromium chromium-browser google-chrome google-chrome-stable)
|
||||
NOVNC_DIRS=(/usr/share/novnc /usr/share/webapps/novnc /opt/novnc)
|
||||
|
||||
missing=()
|
||||
for b in "${BINS[@]}"; do
|
||||
command -v "$b" >/dev/null 2>&1 || missing+=("$b")
|
||||
done
|
||||
chromium=""
|
||||
for c in "${CHROMIUM_CANDIDATES[@]}"; do
|
||||
if command -v "$c" >/dev/null 2>&1; then chromium="$c"; break; fi
|
||||
done
|
||||
[ -z "$chromium" ] && missing+=("chromium")
|
||||
novnc=""
|
||||
for d in "${NOVNC_DIRS[@]}"; do
|
||||
[ -f "$d/vnc.html" ] && { novnc="$d"; break; }
|
||||
done
|
||||
[ -z "$novnc" ] && missing+=("novnc")
|
||||
# CDP 取凭据需要 python websocket 客户端
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 -c 'import websocket' >/dev/null 2>&1 || missing+=("python3-websocket")
|
||||
fi
|
||||
|
||||
if [ ${#missing[@]} -eq 0 ]; then
|
||||
echo "✓ 浏览器栈依赖齐全"
|
||||
echo " chromium: ${chromium}"
|
||||
echo " noVNC: ${novnc}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "缺少依赖: ${missing[*]}"
|
||||
if [ "$CHECK_ONLY" = 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- 识别包管理器 ----------
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
# Debian/Ubuntu:chromium 在部分版本是 snap 包,apt 装不上;
|
||||
# 先试 apt,失败时明确提示替代方案,不静默放弃。
|
||||
pkgs=(xvfb x11vnc websockify novnc python3-websocket)
|
||||
echo "→ apt-get install ${pkgs[*]} chromium"
|
||||
apt-get update -qq || true
|
||||
apt-get install -y "${pkgs[@]}" || {
|
||||
echo "WARN: 部分包装失败,仍尝试装 chromium" >&2
|
||||
}
|
||||
apt-get install -y chromium || apt-get install -y chromium-browser || {
|
||||
echo "WARN: apt 装不上 chromium(Ubuntu 新版将其作为 snap 分发)。" >&2
|
||||
echo " 可选:snap install chromium 或 apt-get install -y chromium-browser" >&2
|
||||
}
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
echo "→ dnf install xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client"
|
||||
dnf install -y xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
echo "→ yum install xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client"
|
||||
yum install -y xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
echo "→ apk add xvfb x11vnc websockify novnc chromium py3-websocket-client"
|
||||
apk add --no-cache xvfb x11vnc websockify novnc chromium py3-websocket-client
|
||||
else
|
||||
echo "未识别的包管理器。请手动安装:" >&2
|
||||
echo " Xvfb, x11vnc, websockify, noVNC, chromium, python3 websocket-client" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- 复核 ----------
|
||||
echo
|
||||
echo "复核安装结果:"
|
||||
ok=1
|
||||
for b in "${BINS[@]}"; do
|
||||
if command -v "$b" >/dev/null 2>&1; then echo " ✓ $b"
|
||||
else echo " ✗ $b"; ok=0; fi
|
||||
done
|
||||
chromium=""
|
||||
for c in "${CHROMIUM_CANDIDATES[@]}"; do
|
||||
if command -v "$c" >/dev/null 2>&1; then chromium="$c"; break; fi
|
||||
done
|
||||
[ -n "$chromium" ] && echo " ✓ $chromium" || { echo " ✗ chromium"; ok=0; }
|
||||
novnc=""
|
||||
for d in "${NOVNC_DIRS[@]}"; do
|
||||
[ -f "$d/vnc.html" ] && { novnc="$d"; break; }
|
||||
done
|
||||
[ -n "$novnc" ] && echo " ✓ noVNC ($novnc)" || { echo " ✗ noVNC"; ok=0; }
|
||||
python3 -c 'import websocket' >/dev/null 2>&1 && echo " ✓ python3 websocket-client" || { echo " ✗ python3 websocket-client"; ok=0; }
|
||||
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo
|
||||
echo "✓ 浏览器栈就绪。重启 llmsproxy 后,flow=vnc 的平台即可用 VNC 兜底登录。"
|
||||
else
|
||||
echo
|
||||
echo "WARN: 仍有缺失项,VNC 兜底在该机器上不可用。插件的 deps/status 端点会一样报告。" >&2
|
||||
exit 1
|
||||
fi
|
||||
@ -41,6 +41,11 @@ contents:
|
||||
dst: /usr/share/llmsproxy/adapters/
|
||||
file_info:
|
||||
mode: 0644
|
||||
# 服务端浏览器栈依赖安装脚本(VNC 兜底登录用;需 root,运维显式执行)
|
||||
- src: ./packaging/install-browser-stack.sh
|
||||
dst: /usr/share/llmsproxy/install-browser-stack.sh
|
||||
file_info:
|
||||
mode: 0755
|
||||
# README pointer
|
||||
- src: ./README.md
|
||||
dst: /usr/share/doc/llmsproxy/README.md
|
||||
|
||||
Reference in New Issue
Block a user