mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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:
@ -244,6 +244,10 @@ type Plugin struct {
|
||||
// was supposed to inherit from.
|
||||
persisted bool
|
||||
|
||||
// AuthHosts lists the login hosts this plugin declared (`plugin.auth_hosts`).
|
||||
// A REQUEST, not a grant: the operator approves in settings.
|
||||
AuthHosts []string
|
||||
|
||||
// KernelDeclared lists the kernel actions this plugin asks for in its own
|
||||
// source (`plugin.kernel = {...}`). It is a REQUEST, shown to the operator
|
||||
// beside what was actually granted; it grants nothing by itself.
|
||||
@ -307,6 +311,20 @@ type Plugins struct {
|
||||
// what a plugin DECLARES it wants (Plugin.KernelDeclared): a declaration is
|
||||
// a request, a grant is a privilege. Persisted in .access.json.
|
||||
kernelPerms kernelPermissionSet
|
||||
// loginStore holds in-flight login sessions (see plugin_login.go). Lazy so a
|
||||
// Plugins built without construction still works.
|
||||
loginStore *loginSessions
|
||||
loginOnce sync.Once
|
||||
// sourceHosts remembers the source-derived allowlist so a rebuild can fold it
|
||||
// together with any granted login hosts.
|
||||
sourceHosts []string
|
||||
// authHosts is the per-plugin set of APPROVED login hosts. Separate from what
|
||||
// a plugin declared (Plugin.AuthHosts): a declaration is a request.
|
||||
authHosts map[string][]string
|
||||
// vncPermitted gates the VNC login escape hatch. Off by default: a
|
||||
// server-side browser is the highest-risk login path and must be an
|
||||
// operator's deliberate choice, not a default.
|
||||
vncPermitted bool
|
||||
// save coalesces state writes into one background goroutine.
|
||||
//
|
||||
// A hook must NOT write synchronously: the billing plugin mutates state on
|
||||
@ -519,7 +537,10 @@ func (ps *Plugins) DisableStatePersistence() { ps.stateDirDisabled = true }
|
||||
func (ps *Plugins) SetOutboundHosts(hosts []string) {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
ps.allowHTTP = newHTTPAllowlist(hosts)
|
||||
// Remembered so a later auth-host grant can rebuild the allowlist as
|
||||
// sources ∪ granted-login-hosts instead of losing the source half.
|
||||
ps.sourceHosts = append([]string(nil), hosts...)
|
||||
ps.rebuildOutboundAllowlistLocked()
|
||||
}
|
||||
|
||||
// outboundAllowlist returns the current allowlist. bootPluginState reads it
|
||||
@ -635,6 +656,9 @@ func bootPluginState(code, name string, allow *httpAllowlist, ps *Plugins) (*wor
|
||||
// the kernel.
|
||||
registerSecretBridge(L, ps, name)
|
||||
registerKernelBridge(L, ps, name)
|
||||
// R4-e: login sessions. Same scoping reason as the rest — an adapter
|
||||
// shares setupGlobals and must not be able to start a login.
|
||||
registerLoginBridge(L, ps, name)
|
||||
}
|
||||
if err := L.DoString(code); err != nil {
|
||||
L.Close()
|
||||
@ -776,6 +800,13 @@ func (ps *Plugins) LoadSource(name, code string) error {
|
||||
p.KernelDeclared = readPluginKernelDeclared(w.L)
|
||||
w.L.SetTop(0)
|
||||
|
||||
// ---- declared login hosts ----
|
||||
// Login flows reach hosts that are not sources (auth.example.com), which the
|
||||
// outbound allowlist blocks by default. A plugin declares what it needs; the
|
||||
// operator approves. See plugin_login.go.
|
||||
p.AuthHosts = readPluginAuthHosts(w.L)
|
||||
w.L.SetTop(0)
|
||||
|
||||
// ---- http endpoints ----
|
||||
p.http = readPluginHTTP(w.L)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user