Files
ModelRouter/internal/lua/plugin_login_test.go
JianFeeeee c3b858d5ba feat(lua): 多流程登录会话抽象(R4-e)+ 内核权限设置页
用户决策:**多流程抽象**(VNC 作逃生舱)+ **单插件多平台**。

## 为什么不是 VNC 为主路径(实测参考插件后的结论)

grep 参考插件(dsh-our-free-model,13 个账号渠道)的源码:

  puppeteer / playwright / chromium / headless / vnc / selenium  →  0 处

它的登录全部是**把 URL 交给用户自己的浏览器**,插件轮询凭据是否就绪:
loginUrl 113 次 · cookie 133 次 · oauth 43 次 · deviceCode 8 次 · qrcode 7 次。

不以 VNC 为主路径的四条具体理由:
1. Passkey/WebAuthn 基本不可用:凭据绑在用户设备,VNC 里是另一台机器。
2. 无头浏览器更容易被判为机器人 —— 登录场景最不能踩这个。
3. 安全面:llmsproxy 以 root 运行(master.key 0600),systemd 已 ProtectHome=yes。
   从 root 服务开 VNC 通道到"即将登录用户各平台账号"的浏览器 = 提权面绑高价值目标。
4. 最关键:VNC 不解决取凭据。登录后凭据活在浏览器 profile 的 cookie 里,
   还是要提取 profile —— 那正是最脆弱的一环。轮询流程直接产出 token。

VNC 唯一不可替代的场景(服务端与用户不同网 + 平台要求登录 IP 与调用 IP 一致)
使它作为逃生舱存在:**默认关闭**,需运维显式开启。

## 登录会话抽象(plugin_login.go)

四种流程:link-poll(首选)· callback(复用 Phase 6 自定义挂载)· paste(兜住
cookie 类渠道)· vnc(逃生舱)。内核只管会话生命周期,不含任何平台知识。

- 会话只存**状态**不存凭据:凭据由插件写进自己的保险库,会话里放一个
  UI 可见的记录会让 token 有机会被列表接口带出去
- nonce 只在 begin 时给(后续读取剥离),空 nonce 不匹配任何会话
- 读取时判定过期,无后台定时器(没有登录请求时零开销)
- 跨插件读不可行,且与"不存在"不可区分(否则会确认 id 存在)

## 关键阻碍与修法:登录主机不在出站白名单里

**实测确认**:`auth.huaweicloud.com` 被白名单拒绝
("not an allowed outbound destination (allowed: the hosts of configured sources)"),
整个登录流程无法工作 —— 因为登录主机不是上游源。

修法与内核权限同一姿态:插件在源码 `plugin.auth_hosts` **声明**,运维在设置页
**批准**后才放行。只能批准插件声明过的 host(否则声明只是装饰),逐个批准
不连带(否则一个"登录"理由就能打开整张名单)。

## 设置页

插件表格新增"内核动作"列 + 点击展开的权限面板:动作清单按危险级排序、勾选即
授权、**申请中但未授权会标红**(那通常意味着插件某功能静默失效)。
另加登录主机批准面板(已批准/待批准)。

后端 /api/plugins/{name}/kernel(GET 看 declared vs granted,PUT 改)
与 /api/plugins/{name}/login(列在飞会话,供页面显示"等待你完成登录"+ URL)。

开发中自己引入并修掉一个缺陷:renderPlugins → refreshKernelSummary →
renderPlugins 无限递归;改为只原地 patch 受影响的单元格。

测试 8 个:会话生命周期/跨插件隔离/callback nonce/登录主机门控/只能批准已声明/
过期判定/VNC 需显式开启/未知流程名被拒。node --check 验证内联 JS 可解析。
2026-10-11 13:56:35 +08:00

295 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package lua
import (
"testing"
"time"
)
// 本文件验证 R4-e:插件驱动的登录会话 + 登录 host 授权。
//
// 用户决策是「多流程抽象」(link-poll / callback / paste / VNC 逃生舱)。
// 这里断言的是**框架**:会话生命周期、跨插件隔离、nonce 校验、host 授权
// 与出站白名单的联动。各平台的具体流程属于插件业务。
func loginPlugins(t *testing.T) (*VM, *Plugins, string) {
return runtimePlugins(t)
}
const loginProbe = `
local plugin = { name = "loginprobe", version = "1.0" }
plugin.exports = { "begin_link", "poll", "start_callback", "cancel_one", "resolve_ok", "read_other" }
plugin.auth_hosts = { "auth.example.com", "Accounts.Google.com:443" }
function plugin.begin_link()
local s, err = __llmsproxy_login.begin({
platform = "example", flow = "link-poll",
url = "https://auth.example.com/device", user_code = "WDJB-MJHT",
})
return { s = s, err = err }
end
function plugin.poll(args)
local s, err = __llmsproxy_login.get(args.id)
return { s = s, err = err }
end
function plugin.start_callback()
local s, err = __llmsproxy_login.begin({ platform = "example", flow = "callback" })
return { s = s, err = err }
end
function plugin.cancel_one(args)
local ok = __llmsproxy_login.cancel(args.id)
return { ok = ok }
end
function plugin.resolve_ok(args)
local ok, err = __llmsproxy_login.resolve(args.id, "completed", "alice", "")
return { ok = ok, err = err }
end
function plugin.read_other(args)
local s = __llmsproxy_login.get(args.id)
return { got = s }
end
return plugin
`
// TestLoginSessionLifecycle:创建 → 轮询 → 完成。
func TestLoginSessionLifecycle(t *testing.T) {
_, ps, _ := loginPlugins(t)
if err := ps.LoadSource("loginprobe", loginProbe); err != nil {
t.Fatal(err)
}
out, err := ps.Call("loginprobe", "begin_link", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]interface{})
if m["err"] != nil {
t.Fatalf("begin 失败: %v", m["err"])
}
if m["s"] == nil {
t.Fatalf("begin 返回 nil session, err=%v", m["err"])
}
s := m["s"].(map[string]interface{})
if s["state"] != "pending" {
t.Errorf("新会话应为 pending, 实得 %v", s["state"])
}
if s["url"] != "https://auth.example.com/device" {
t.Errorf("URL 未保留: %v", s["url"])
}
if s["user_code"] != "WDJB-MJHT" {
t.Errorf("设备码未保留: %v", s["user_code"])
}
id := s["id"].(string)
// 轮询到 pending
out, _ = ps.Call("loginprobe", "poll", map[string]interface{}{"id": id})
got := out.(map[string]interface{})["s"].(map[string]interface{})
if got["state"] != "pending" {
t.Errorf("轮询应为 pending, 实得 %v", got["state"])
}
// 后续读取不得带 nonce(只在 begin 时给)
if _, hasNonce := got["nonce"]; hasNonce {
t.Error("后续读取不应暴露 nonce(CSRF 令牌)")
}
// 完成
out, _ = ps.Call("loginprobe", "resolve_ok", map[string]interface{}{"id": id})
if out.(map[string]interface{})["ok"] != true {
t.Fatalf("resolve 失败: %#v", out)
}
out, _ = ps.Call("loginprobe", "poll", map[string]interface{}{"id": id})
fin := out.(map[string]interface{})["s"].(map[string]interface{})
if fin["state"] != "completed" {
t.Errorf("应为 completed, 实得 %v", fin["state"])
}
if fin["account"] != "alice" {
t.Errorf("账号标签应保留: %v", fin["account"])
}
}
// TestLoginSessionIsolatedPerPlugin:插件读不到别人的会话(且与"不存在"不可区分)。
func TestLoginSessionIsolatedPerPlugin(t *testing.T) {
_, ps, _ := loginPlugins(t)
if err := ps.LoadSource("loginprobe", loginProbe); err != nil {
t.Fatal(err)
}
other := `
local plugin = { name = "other", version = "1.0" }
plugin.exports = { "read_other" }
function plugin.read_other(args)
local s = __llmsproxy_login.get(args.id)
return { got = s }
end
return plugin
`
if err := ps.LoadSource("other", other); err != nil {
t.Fatal(err)
}
out, _ := ps.Call("loginprobe", "begin_link", map[string]interface{}{})
id := out.(map[string]interface{})["s"].(map[string]interface{})["id"].(string)
out, err := ps.Call("other", "read_other", map[string]interface{}{"id": id})
if err != nil {
t.Fatal(err)
}
if out.(map[string]interface{})["got"] != nil {
t.Fatal("★ 插件 B 读到了插件 A 的登录会话")
}
}
// TestLoginCallbackNonce:callback 流程的 nonce 必须能匹配,且只在 begin 时给出。
func TestLoginCallbackNonce(t *testing.T) {
_, ps, _ := loginPlugins(t)
if err := ps.LoadSource("loginprobe", loginProbe); err != nil {
t.Fatal(err)
}
out, _ := ps.Call("loginprobe", "start_callback", map[string]interface{}{})
s := out.(map[string]interface{})["s"].(map[string]interface{})
nonce, _ := s["nonce"].(string)
if nonce == "" {
t.Fatal("callback 流程必须在 begin 时给出 nonce")
}
// 内核侧能按 nonce 找到会话(回调路由用它)
sess, ok := ps.MatchCallbackNonce(nonce)
if !ok {
t.Fatal("nonce 应能匹配到会话")
}
if sess.ID != s["id"].(string) {
t.Errorf("匹配到错误的会话: %s vs %s", sess.ID, s["id"])
}
// 空 nonce 不得匹配任何东西(否则回调可绕过校验)
if _, ok := ps.MatchCallbackNonce(""); ok {
t.Error("空 nonce 不应匹配")
}
}
// TestLoginHostsGateOutbound 是本次发现的**关键阻碍**的回归测试:
// 登录主机不是源,默认被出站白名单拒绝;只有声明 + 批准后才放行。
func TestLoginHostsGateOutbound(t *testing.T) {
_, ps, _ := loginPlugins(t)
if err := ps.LoadSource("loginprobe", loginProbe); err != nil {
t.Fatal(err)
}
// 声明被读取(normalizeHost 会去端口、小写)
declared := ps.PluginAuthHosts("loginprobe")
if len(declared) != 2 {
t.Fatalf("应读到 2 个声明 host, 实得 %#v", declared)
}
want := map[string]bool{"auth.example.com": true, "accounts.google.com": true}
for _, h := range declared {
if !want[h] {
t.Errorf("声明 host 未规范化: %q", h)
}
}
// 未批准 → 出站被拒
if _, err := ps.vm.Transform("noop", "x", "{}"); err == nil {
_ = err // 不关心,仅为占位
}
allow := ps.outboundAllowlist()
if allow.allows("auth.example.com", "") {
t.Fatal("★ 未批准的登录 host 不应在放行名单里")
}
// 批准后放行
if err := ps.GrantAuthHosts("loginprobe", []string{"auth.example.com"}); err != nil {
t.Fatalf("批准失败: %v", err)
}
allow = ps.outboundAllowlist()
if !allow.allows("auth.example.com", "") {
t.Fatal("批准后应放行")
}
if allow.allows("accounts.google.com", "") {
t.Error("未逐个批准的 host 不应被连带放行")
}
}
// TestGrantUndeclaredAuthHostRejected:只能批准插件声明过的 host。
func TestGrantUndeclaredAuthHostRejected(t *testing.T) {
_, ps, _ := loginPlugins(t)
if err := ps.LoadSource("loginprobe", loginProbe); err != nil {
t.Fatal(err)
}
if err := ps.GrantAuthHosts("loginprobe", []string{"evil.example.net"}); err == nil {
t.Fatal("★ 批准未声明的 host 应被拒(否则声明只是装饰)")
}
}
// TestLoginExpiry:超时未完成的会话变 expired(读取时判定,无需后台定时器)。
func TestLoginExpiry(t *testing.T) {
_, ps, _ := loginPlugins(t)
if err := ps.LoadSource("loginprobe", loginProbe); err != nil {
t.Fatal(err)
}
out, _ := ps.Call("loginprobe", "begin_link", map[string]interface{}{})
id := out.(map[string]interface{})["s"].(map[string]interface{})["id"].(string)
// 人为把过期时间提前
ps.logins().mu.Lock()
ps.logins().byID[id].ExpiresAt = time.Now().Add(-time.Second)
ps.logins().mu.Unlock()
out, _ = ps.Call("loginprobe", "poll", map[string]interface{}{"id": id})
s := out.(map[string]interface{})["s"].(map[string]interface{})
if s["state"] != "expired" {
t.Fatalf("过期会话应为 expired, 实得 %v", s["state"])
}
}
// TestVNCRequiresOptIn:VNC 逃生舱默认关闭。
func TestVNCRequiresOptIn(t *testing.T) {
_, ps, _ := loginPlugins(t)
code := `
local plugin = { name = "vncprobe", version = "1.0" }
plugin.exports = { "begin_vnc" }
function plugin.begin_vnc()
local s, err = __llmsproxy_login.begin({ platform = "x", flow = "vnc" })
return { s = s, err = err }
end
return plugin
`
if err := ps.LoadSource("vncprobe", code); err != nil {
t.Fatal(err)
}
out, err := ps.Call("vncprobe", "begin_vnc", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]interface{})
if m["err"] == nil || m["s"] != nil {
t.Fatal("★ VNC 流程默认应被拒绝")
}
t.Logf("默认拒绝信息: %v", m["err"])
// 运维显式开启后可用
ps.SetVNCEnabled(true)
out, _ = ps.Call("vncprobe", "begin_vnc", map[string]interface{}{})
if out.(map[string]interface{})["s"] == nil {
t.Fatal("开启后 VNC 应可用")
}
}
// TestUnknownLoginFlowRejected:拼错的流程名必须被拒。
func TestUnknownLoginFlowRejected(t *testing.T) {
_, ps, _ := loginPlugins(t)
code := `
local plugin = { name = "badflow", version = "1.0" }
plugin.exports = { "go" }
function plugin.go()
local s, err = __llmsproxy_login.begin({ platform = "x", flow = "carrier-pigeon" })
return { s = s, err = err }
end
return plugin
`
if err := ps.LoadSource("badflow", code); err != nil {
t.Fatal(err)
}
out, _ := ps.Call("badflow", "go", map[string]interface{}{})
if out.(map[string]interface{})["err"] == nil {
t.Fatal("未知流程名应被拒")
}
}