Files
ModelRouter/internal/lua/plugin_login.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

458 lines
16 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 (
"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)
}