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 可解析。
This commit is contained in:
JianFeeeee
2026-10-11 13:56:35 +08:00
parent a59295e826
commit c3b858d5ba
7 changed files with 1316 additions and 3 deletions

View File

@ -0,0 +1,294 @@
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("未知流程名应被拒")
}
}