mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-09 18:57:21 +00:00
用户决策:**多流程抽象**(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 可解析。
458 lines
16 KiB
Go
458 lines
16 KiB
Go
package lua
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// Package lua — 登录会话抽象(R4-e)
|
||
//
|
||
// 用户需求:把"同类插件"做出来 —— 插件自己**驱动外部平台的链接认证**,拿到
|
||
// 凭据后把平台接进网关当源用;配独立标签页做登录/认证/配置。
|
||
//
|
||
// 用户原提议:服务端通过 VNC 穿透一个无头浏览器,用户在自己浏览器里操作。
|
||
// 实测参考插件(dsh-our-free-model,13 个账号渠道)后的结论是:
|
||
//
|
||
// puppeteer / playwright / chromium / headless / vnc / selenium → 0 处
|
||
//
|
||
// 它的登录是**把 URL 交给用户自己的浏览器**,插件轮询凭据是否就绪:
|
||
//
|
||
// loginUrl 113 次 · cookie 133 次 · oauth 43 次 · deviceCode 8 次 · qrcode 7 次
|
||
//
|
||
// 所以本文件实现**多流程**抽象,VNC 是其中一条默认关闭的逃生舱:
|
||
//
|
||
// link-poll 给 URL,插件轮询结果 ← 首选,覆盖设备码/OAuth
|
||
// callback OAuth 重定向回来带 code ← 复用已有的自定义挂载点
|
||
// paste 用户把 cookie/token 粘回来 ← 兜住 cookie 类渠道
|
||
// vnc 服务端出浏览器 ← 逃生舱,默认关闭
|
||
//
|
||
// 为什么不以 VNC 为主路径(四条具体理由,非偏好):
|
||
//
|
||
// 1. Passkey/WebAuthn 基本不可用:凭据绑在用户设备,VNC 里是另一台机器,
|
||
// 跨设备流程要蓝牙/扫码,而 VNC 延迟让这体验很差。
|
||
// 2. 无头浏览器更容易被判为机器人,不是更少 —— 登录场景最不能踩这个。
|
||
// 3. 安全面:llmsproxy 以 root 运行(master.key 是 0600 root),systemd 已加
|
||
// ProtectHome=yes。从 root 服务开一条 VNC 通道到一个"即将登录用户各平台
|
||
// 账号"的浏览器,等于把提权面与高价值目标绑在一起。
|
||
// 4. 最关键:**它不解决取凭据**。VNC 登录后凭据活在浏览器 profile 的 cookie
|
||
// 里,不在插件里;还是要提取 profile,而那正是最脆弱的一环。轮询流程是
|
||
// 直接产出 token 的。
|
||
//
|
||
// VNC 唯一不可替代的场景:服务端与用户不同网,且平台要求登录 IP 与调用 IP
|
||
// 一致(区域限制)。所以它作为可选项存在,而不是地基。
|
||
|
||
// LoginFlow names one way a plugin can obtain credentials for a platform.
|
||
type LoginFlow string
|
||
|
||
const (
|
||
// FlowLinkPoll: the plugin mints a URL (device code / OAuth authorize), the
|
||
// operator opens it in their OWN browser, and the plugin polls until the
|
||
// platform reports the credential. This is the reference implementation's
|
||
// dominant pattern and needs no browser on the server.
|
||
FlowLinkPoll LoginFlow = "link-poll"
|
||
// FlowCallback: a standard OAuth redirect. The plugin claims a route (the
|
||
// custom mount points from Phase 6 already allow /plugins/<name>/...) and the
|
||
// platform's redirect lands there with the code. The session carries the
|
||
// state nonce so a callback cannot be replayed into the wrong session.
|
||
FlowCallback LoginFlow = "callback"
|
||
// FlowPaste: the operator pastes a cookie or token into the settings page.
|
||
// Covers the cookie-based channels (133 hits in the reference plugin) where
|
||
// no OAuth exists at all.
|
||
FlowPaste LoginFlow = "paste"
|
||
// FlowVNC: a server-side browser exposed over VNC. Opt-in escape hatch, off
|
||
// by default (see the header note).
|
||
FlowVNC LoginFlow = "vnc"
|
||
)
|
||
|
||
// LoginSessionState is the lifecycle of one attempt.
|
||
type LoginSessionState string
|
||
|
||
const (
|
||
LoginPending LoginSessionState = "pending"
|
||
LoginCompleted LoginSessionState = "completed"
|
||
LoginFailed LoginSessionState = "failed"
|
||
LoginExpired LoginSessionState = "expired"
|
||
LoginCancelled LoginSessionState = "cancelled"
|
||
)
|
||
|
||
// LoginSession is one in-flight authentication attempt.
|
||
//
|
||
// It is created by a plugin (or the settings page) and polled until it reaches a
|
||
// terminal state. The credential itself is never stored here — the plugin stores
|
||
// it in its own vault (__llmsproxy_secrets) and reports bare completion. Keeping
|
||
// tokens out of the session record means a session listing, which IS exposed to
|
||
// the UI, can never leak one.
|
||
type LoginSession struct {
|
||
ID string `json:"id"`
|
||
Plugin string `json:"plugin"`
|
||
Platform string `json:"platform"`
|
||
Flow LoginFlow `json:"flow"`
|
||
State LoginSessionState `json:"state"`
|
||
// URL is what the operator must open (link-poll) or the VNC entry point.
|
||
URL string `json:"url,omitempty"`
|
||
// UserCode is the short code a device flow shows (e.g. "WDJB-MJHT").
|
||
UserCode string `json:"user_code,omitempty"`
|
||
// State nonce for the callback flow. A callback whose state does not match is
|
||
// refused, so an attacker cannot inject a code into someone else's session.
|
||
Nonce string `json:"-"`
|
||
// Message explains a non-pending state in the operator's terms.
|
||
Message string `json:"message,omitempty"`
|
||
// Account is a display label the plugin reports on success (a login name),
|
||
// never a secret.
|
||
Account string `json:"account,omitempty"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
ExpiresAt time.Time `json:"expires_at"`
|
||
}
|
||
|
||
// loginSessionTTL bounds how long a pending attempt lives. A login the operator
|
||
// abandoned must not leave a pollable session (and a browser tab prompt) forever;
|
||
// the reference implementation uses the same 15 minute idea.
|
||
const loginSessionTTL = 15 * time.Minute
|
||
|
||
// loginSessions owns all in-flight attempts.
|
||
type loginSessions struct {
|
||
mu sync.Mutex
|
||
byID map[string]*LoginSession
|
||
}
|
||
|
||
func newLoginSessions() *loginSessions {
|
||
return &loginSessions{byID: map[string]*LoginSession{}}
|
||
}
|
||
|
||
// newLoginID returns an unguessable session id.
|
||
//
|
||
// Unguessable matters: the id is the handle the settings page polls, and the
|
||
// callback route carries its nonce. A sequential id would let a caller enumerate
|
||
// other plugins' pending logins.
|
||
func newLoginID() string {
|
||
var b [12]byte
|
||
if _, err := rand.Read(b[:]); err != nil {
|
||
// crypto/rand failing is not a condition this process can serve around;
|
||
// fall back to a time-based id rather than returning empty.
|
||
return fmt.Sprintf("ls-%d", time.Now().UnixNano())
|
||
}
|
||
return "ls-" + hex.EncodeToString(b[:])
|
||
}
|
||
|
||
// newLoginNonce returns the callback state nonce.
|
||
func newLoginNonce() string {
|
||
var b [16]byte
|
||
if _, err := rand.Read(b[:]); err != nil {
|
||
return fmt.Sprintf("n-%d", time.Now().UnixNano())
|
||
}
|
||
return hex.EncodeToString(b[:])
|
||
}
|
||
|
||
// BeginLogin starts a session. The plugin decides the flow and supplies what
|
||
// that flow needs; the kernel only owns the lifecycle.
|
||
func (ps *Plugins) BeginLogin(s LoginSession) (*LoginSession, error) {
|
||
if s.Plugin == "" {
|
||
return nil, fmt.Errorf("login session requires a plugin")
|
||
}
|
||
if ps.find(s.Plugin) == nil {
|
||
return nil, fmt.Errorf("plugin %s not found", s.Plugin)
|
||
}
|
||
switch s.Flow {
|
||
case FlowLinkPoll, FlowCallback, FlowPaste, FlowVNC:
|
||
default:
|
||
return nil, fmt.Errorf("unknown login flow %q", s.Flow)
|
||
}
|
||
if s.Flow == FlowVNC && !ps.VNCEnabled() {
|
||
return nil, fmt.Errorf("the VNC login flow is disabled; enable it in settings if the platform " +
|
||
"requires a server-side browser (it is off by default for security)")
|
||
}
|
||
now := time.Now()
|
||
out := &LoginSession{
|
||
ID: newLoginID(),
|
||
Plugin: s.Plugin,
|
||
Platform: s.Platform,
|
||
Flow: s.Flow,
|
||
State: LoginPending,
|
||
URL: s.URL,
|
||
UserCode: s.UserCode,
|
||
Nonce: newLoginNonce(),
|
||
CreatedAt: now,
|
||
ExpiresAt: now.Add(loginSessionTTL),
|
||
}
|
||
ps.logins().mu.Lock()
|
||
ps.logins().sweepLocked(now)
|
||
ps.logins().byID[out.ID] = out
|
||
ps.logins().mu.Unlock()
|
||
cp := out.clone()
|
||
return &cp, nil
|
||
}
|
||
|
||
// GetLogin returns one session, expiring it if its TTL has passed.
|
||
//
|
||
// Expiry is applied on READ rather than by a background timer: a gateway with no
|
||
// logins in flight pays nothing, and a session can never be observed as pending
|
||
// after its deadline even if no goroutine happens to have run.
|
||
func (ps *Plugins) GetLogin(id string) (*LoginSession, bool) {
|
||
now := time.Now()
|
||
ls := ps.logins()
|
||
ls.mu.Lock()
|
||
defer ls.mu.Unlock()
|
||
ls.sweepLocked(now)
|
||
s, ok := ls.byID[id]
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
cp := s.clone()
|
||
return &cp, true
|
||
}
|
||
|
||
// ResolveLogin moves a session to a terminal state. It is the only way a session
|
||
// ends successfully, so the credential handling stays in one place.
|
||
//
|
||
// `account` is a display label only — never a token. The plugin has already
|
||
// stored the credential in its vault by the time it calls this; passing it
|
||
// through here would put it in a UI-visible record.
|
||
func (ps *Plugins) ResolveLogin(id string, state LoginSessionState, account, message string) error {
|
||
switch state {
|
||
case LoginCompleted, LoginFailed, LoginExpired, LoginCancelled:
|
||
default:
|
||
return fmt.Errorf("resolveLogin needs a terminal state, got %q", state)
|
||
}
|
||
ls := ps.logins()
|
||
ls.mu.Lock()
|
||
defer ls.mu.Unlock()
|
||
s, ok := ls.byID[id]
|
||
if !ok {
|
||
return fmt.Errorf("no such login session")
|
||
}
|
||
if s.isTerminal() {
|
||
// Already finished. Reporting success again would let a plugin turn a
|
||
// cancelled session into a completed one.
|
||
return fmt.Errorf("login session is already %s", s.State)
|
||
}
|
||
s.State = state
|
||
s.Account = account
|
||
s.Message = message
|
||
return nil
|
||
}
|
||
|
||
// MatchCallbackNonce finds the pending callback session for a nonce.
|
||
//
|
||
// The nonce is the CSRF defence for the callback flow: a redirect that does not
|
||
// carry the nonce of a live session is not answered, so a code delivered to an
|
||
// attacker-controlled session cannot be replayed into this gateway's login.
|
||
func (ps *Plugins) MatchCallbackNonce(nonce string) (*LoginSession, bool) {
|
||
if nonce == "" {
|
||
return nil, false
|
||
}
|
||
now := time.Now()
|
||
ls := ps.logins()
|
||
ls.mu.Lock()
|
||
defer ls.mu.Unlock()
|
||
ls.sweepLocked(now)
|
||
for _, s := range ls.byID {
|
||
if s.Nonce == nonce && s.Flow == FlowCallback && !s.isTerminal() {
|
||
cp := s.clone()
|
||
return &cp, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// ListLogins returns a plugin's sessions, newest first, for its settings page.
|
||
func (ps *Plugins) ListLogins(plugin string) []LoginSession {
|
||
now := time.Now()
|
||
ls := ps.logins()
|
||
ls.mu.Lock()
|
||
defer ls.mu.Unlock()
|
||
ls.sweepLocked(now)
|
||
out := make([]LoginSession, 0, 4)
|
||
for _, s := range ls.byID {
|
||
if s.Plugin != plugin {
|
||
continue
|
||
}
|
||
cp := s.clone()
|
||
cp.Nonce = "" // never expose the nonce to the UI
|
||
out = append(out, cp)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||
return out
|
||
}
|
||
|
||
// CancelLogins drops a plugin's pending sessions. Used on plugin unload so an
|
||
// uninstalled plugin leaves no pollable login behind.
|
||
func (ps *Plugins) CancelLogins(plugin string) int {
|
||
ls := ps.logins()
|
||
ls.mu.Lock()
|
||
defer ls.mu.Unlock()
|
||
n := 0
|
||
for _, s := range ls.byID {
|
||
if s.Plugin == plugin && !s.isTerminal() {
|
||
s.State = LoginCancelled
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// sweepLocked expires overdue sessions and reaps terminal ones. Caller holds mu.
|
||
func (ls *loginSessions) sweepLocked(now time.Time) {
|
||
for id, s := range ls.byID {
|
||
if !s.isTerminal() && now.After(s.ExpiresAt) {
|
||
s.State = LoginExpired
|
||
s.Message = "the login attempt timed out; start it again"
|
||
}
|
||
// Terminal sessions are reaped after a grace period so the UI can show
|
||
// the outcome. Deleting immediately would make a completed login look
|
||
// like it never happened.
|
||
if s.isTerminal() && now.After(s.ExpiresAt.Add(loginSessionTTL)) {
|
||
delete(ls.byID, id)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *LoginSession) isTerminal() bool {
|
||
switch s.State {
|
||
case LoginCompleted, LoginFailed, LoginExpired, LoginCancelled:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// clone returns a copy so callers cannot mutate the stored session, and so a
|
||
// data race cannot expose a half-updated struct to the UI.
|
||
func (s *LoginSession) clone() LoginSession {
|
||
cp := *s
|
||
return cp
|
||
}
|
||
|
||
// loginSessions lazily creates the store so a Plugins value built in a test
|
||
// without construction still works.
|
||
func (ps *Plugins) logins() *loginSessions {
|
||
ps.loginOnce.Do(func() {
|
||
if ps.loginStore == nil {
|
||
ps.loginStore = newLoginSessions()
|
||
}
|
||
})
|
||
return ps.loginStore
|
||
}
|
||
|
||
// VNCEnabled reports whether the VNC escape hatch is on. Off by default: a
|
||
// server-side browser is the highest-risk login path and must be a deliberate
|
||
// operator choice.
|
||
func (ps *Plugins) VNCEnabled() bool {
|
||
ps.mu.RLock()
|
||
defer ps.mu.RUnlock()
|
||
return ps.vncPermitted
|
||
}
|
||
|
||
// SetVNCEnabled turns the VNC login flow on or off.
|
||
func (ps *Plugins) SetVNCEnabled(on bool) {
|
||
ps.mu.Lock()
|
||
ps.vncPermitted = on
|
||
ps.mu.Unlock()
|
||
}
|
||
|
||
// ---- 登录主机授权 ----
|
||
//
|
||
// 登录流程要访问的 host(auth.huaweicloud.com、accounts.google.com…)不是源,
|
||
// 所以出站白名单默认会掐断整个流程(已实测确认)。这是**正确**的默认:白名单
|
||
// 的意义就是"插件只能去它该去的地方"。
|
||
//
|
||
// 解法与内核权限同一个姿态:插件在源码里**声明**它需要哪些登录 host
|
||
// (plugin.auth_hosts),运维在设置页批准后才放行。声明是请求,批准才是授权。
|
||
//
|
||
// 为什么要声明而不是给个通用开关:一个能访问任意 host 的插件等于没有白名单,
|
||
// 而"登录"这个理由足够正当,正当到很容易被用来把整张名单打开。要求逐个列出,
|
||
// 运维才有机会看出"这个插件为什么要访问一个我没听说过的域名"。
|
||
|
||
// PluginAuthHosts returns the login hosts a plugin declared.
|
||
func (ps *Plugins) PluginAuthHosts(plugin string) []string {
|
||
ps.mu.RLock()
|
||
defer ps.mu.RUnlock()
|
||
for _, p := range ps.plugins {
|
||
if p.Info.Name == plugin {
|
||
return append([]string(nil), p.AuthHosts...)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// GrantAuthHosts approves a set of login hosts for a plugin.
|
||
//
|
||
// Only hosts the plugin DECLARED may be granted: accepting an arbitrary host from
|
||
// the settings page would make the declaration decorative, and the point of the
|
||
// declaration is that the operator reviews a list the plugin itself committed to
|
||
// in source that is on disk and reviewable.
|
||
func (ps *Plugins) GrantAuthHosts(plugin string, hosts []string) error {
|
||
if ps.find(plugin) == nil {
|
||
return fmt.Errorf("plugin %s not found", plugin)
|
||
}
|
||
declared := map[string]bool{}
|
||
for _, h := range ps.PluginAuthHosts(plugin) {
|
||
declared[normalizeHost(h)] = true
|
||
}
|
||
clean := make([]string, 0, len(hosts))
|
||
for _, h := range hosts {
|
||
hn := normalizeHost(h)
|
||
if hn == "" {
|
||
continue
|
||
}
|
||
if !declared[hn] {
|
||
return fmt.Errorf("plugin %s did not declare auth host %q", plugin, hn)
|
||
}
|
||
clean = append(clean, hn)
|
||
}
|
||
sort.Strings(clean)
|
||
ps.mu.Lock()
|
||
if ps.authHosts == nil {
|
||
ps.authHosts = map[string][]string{}
|
||
}
|
||
if len(clean) == 0 {
|
||
delete(ps.authHosts, plugin)
|
||
} else {
|
||
ps.authHosts[plugin] = clean
|
||
}
|
||
ps.rebuildOutboundAllowlistLocked()
|
||
ps.saveKernelPermissionsLocked()
|
||
ps.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// AuthHostsGranted returns the approved hosts for each plugin (for the UI).
|
||
func (ps *Plugins) AuthHostsGranted() map[string][]string {
|
||
ps.mu.RLock()
|
||
defer ps.mu.RUnlock()
|
||
out := make(map[string][]string, len(ps.authHosts))
|
||
for k, v := range ps.authHosts {
|
||
out[k] = append([]string(nil), v...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// normalizeHost lowercases a host and strips a port, so "Auth.Example.com:443"
|
||
// and "auth.example.com" cannot both be granted under different names.
|
||
func normalizeHost(h string) string {
|
||
h = strings.ToLower(strings.TrimSpace(h))
|
||
if i := strings.LastIndex(h, ":"); i > 0 && !strings.Contains(h[i:], "]") {
|
||
// Leave IPv6 literals ("[::1]:443") alone apart from the port.
|
||
h = h[:i]
|
||
}
|
||
h = strings.Trim(h, "[]")
|
||
return h
|
||
}
|
||
|
||
// rebuildOutboundAllowlistLocked folds the SOURCE hosts and every GRANTED auth
|
||
// host into the allowlist the HTTP bridge consults. Caller holds ps.mu.
|
||
//
|
||
// Declared-but-not-granted hosts are deliberately excluded: that exclusion is the
|
||
// whole security property, so it is the one thing this function must not be
|
||
// tempted to "helpfully" relax.
|
||
func (ps *Plugins) rebuildOutboundAllowlistLocked() {
|
||
hosts := append([]string(nil), ps.sourceHosts...)
|
||
for _, granted := range ps.authHosts {
|
||
hosts = append(hosts, granted...)
|
||
}
|
||
ps.allowHTTP = newHTTPAllowlist(hosts)
|
||
}
|