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,457 @@
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)
}