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:
@ -450,3 +450,39 @@ func (p KernelPermission) marshal() jsonPermission {
|
||||
func (j jsonPermission) unmarshal() KernelPermission {
|
||||
return KernelPermission{Actions: j.Actions, AllRead: j.AllRead}
|
||||
}
|
||||
|
||||
// KernelActionView is one action as the settings page needs it: the catalog entry
|
||||
// plus whether THIS plugin currently holds it and whether the plugin asked for it.
|
||||
type KernelActionView struct {
|
||||
KernelAction
|
||||
Granted bool `json:"granted"`
|
||||
Declared bool `json:"declared"`
|
||||
}
|
||||
|
||||
// KernelActionsView lists the catalog annotated for one plugin.
|
||||
//
|
||||
// Granted and Declared are both reported because they answer different questions:
|
||||
// "may this plugin do it" and "did this plugin ask for it". An action that is
|
||||
// declared but not granted is the one an operator most needs to notice.
|
||||
func (ps *Plugins) KernelActionsView(plugin string) []KernelActionView {
|
||||
perm := ps.PluginKernelPermission(plugin)
|
||||
declared := map[string]bool{}
|
||||
ps.mu.RLock()
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == plugin {
|
||||
for _, d := range p.KernelDeclared {
|
||||
declared[d] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
out := make([]KernelActionView, 0, len(kernelCatalog))
|
||||
for _, a := range KernelActions() {
|
||||
out = append(out, KernelActionView{
|
||||
KernelAction: a,
|
||||
Granted: perm.allows(a),
|
||||
Declared: declared[a.Name],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@ -243,3 +244,213 @@ func readPluginKernelDeclared(L *golua.State) []string {
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// loginGlobal is the Lua name of the login-session bridge.
|
||||
const loginGlobal = "__llmsproxy_login"
|
||||
|
||||
// registerLoginBridge installs the login session API.
|
||||
//
|
||||
// A plugin drives the whole flow itself (it knows the platform's endpoints) and
|
||||
// the kernel only owns the session lifecycle: create, poll, resolve, cancel.
|
||||
// That split is what lets one kernel serve OAuth, device-code, QR and paste
|
||||
// flows without containing any platform knowledge.
|
||||
func registerLoginBridge(L *golua.State, ps *Plugins, name string) {
|
||||
L.NewTable()
|
||||
L.PushGoClosure(luaLoginBegin(ps, name))
|
||||
L.SetField(-2, "begin")
|
||||
L.PushGoClosure(luaLoginGet(ps, name))
|
||||
L.SetField(-2, "get")
|
||||
L.PushGoClosure(luaLoginResolve(ps, name))
|
||||
L.SetField(-2, "resolve")
|
||||
L.PushGoClosure(luaLoginList(ps, name))
|
||||
L.SetField(-2, "list")
|
||||
L.PushGoClosure(luaLoginCancel(ps, name))
|
||||
L.SetField(-2, "cancel")
|
||||
L.SetGlobal(loginGlobal)
|
||||
}
|
||||
|
||||
// luaLoginBegin: begin({platform, flow, url, user_code}) -> (session, nil) | (nil, err)
|
||||
func luaLoginBegin(ps *Plugins, name string) golua.LuaGoFunction {
|
||||
return func(L *golua.State) int {
|
||||
args, err := luaTableArg(L, 1)
|
||||
if err != nil {
|
||||
L.PushNil()
|
||||
L.PushString("__llmsproxy_login.begin: " + err.Error())
|
||||
return 2
|
||||
}
|
||||
s := LoginSession{
|
||||
Plugin: name,
|
||||
Platform: asString(args["platform"]),
|
||||
Flow: LoginFlow(asString(args["flow"])),
|
||||
URL: asString(args["url"]),
|
||||
UserCode: asString(args["user_code"]),
|
||||
}
|
||||
out, err := ps.BeginLogin(s)
|
||||
if err != nil {
|
||||
L.PushNil()
|
||||
L.PushString(err.Error())
|
||||
return 2
|
||||
}
|
||||
pushGoValue(L, sessionToLua(*out, true))
|
||||
L.PushNil()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// luaLoginGet: get(id) -> (session, nil) | (nil, err)
|
||||
func luaLoginGet(ps *Plugins, name string) golua.LuaGoFunction {
|
||||
return func(L *golua.State) int {
|
||||
id := L.ToString(1)
|
||||
s, ok := ps.GetLogin(id)
|
||||
if !ok {
|
||||
L.PushNil()
|
||||
L.PushNil() // absent is not an error; the plugin tests for nil
|
||||
return 2
|
||||
}
|
||||
if s.Plugin != name {
|
||||
// Another plugin's session is indistinguishable from a missing one.
|
||||
// Reporting "not yours" would confirm the id exists.
|
||||
L.PushNil()
|
||||
L.PushNil()
|
||||
return 2
|
||||
}
|
||||
pushGoValue(L, sessionToLua(*s, false))
|
||||
L.PushNil()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// luaLoginResolve: resolve(id, state, account, message) -> (true, nil) | (nil, err)
|
||||
func luaLoginResolve(ps *Plugins, name string) golua.LuaGoFunction {
|
||||
return func(L *golua.State) int {
|
||||
id := L.ToString(1)
|
||||
state := LoginSessionState(L.ToString(2))
|
||||
account := L.ToString(3)
|
||||
message := L.ToString(4)
|
||||
s, ok := ps.GetLogin(id)
|
||||
if !ok || s.Plugin != name {
|
||||
L.PushNil()
|
||||
L.PushString("no such login session")
|
||||
return 2
|
||||
}
|
||||
if err := ps.ResolveLogin(id, state, account, message); err != nil {
|
||||
L.PushNil()
|
||||
L.PushString(err.Error())
|
||||
return 2
|
||||
}
|
||||
L.PushBoolean(true)
|
||||
L.PushNil()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// luaLoginList: list() -> (array of sessions, nil)
|
||||
func luaLoginList(ps *Plugins, name string) golua.LuaGoFunction {
|
||||
return func(L *golua.State) int {
|
||||
sessions := ps.ListLogins(name)
|
||||
L.NewTable()
|
||||
for i, s := range sessions {
|
||||
pushGoValue(L, sessionToLua(s, false))
|
||||
L.RawSeti(-2, i+1)
|
||||
}
|
||||
L.PushNil()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// luaLoginCancel: cancel(id) -> (true, nil)
|
||||
func luaLoginCancel(ps *Plugins, name string) golua.LuaGoFunction {
|
||||
return func(L *golua.State) int {
|
||||
id := L.ToString(1)
|
||||
s, ok := ps.GetLogin(id)
|
||||
if ok && s.Plugin == name {
|
||||
_ = ps.ResolveLogin(id, LoginCancelled, "", "cancelled by the plugin")
|
||||
}
|
||||
L.PushBoolean(true)
|
||||
L.PushNil()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// sessionToLua renders a session for Lua. The nonce is included ONLY on begin
|
||||
// (the plugin needs it to build its redirect_uri); subsequent reads omit it so a
|
||||
// plugin that logs or forwards a session cannot leak the CSRF token.
|
||||
func sessionToLua(s LoginSession, includeNonce bool) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"id": s.ID,
|
||||
"platform": s.Platform,
|
||||
"flow": string(s.Flow),
|
||||
"state": string(s.State),
|
||||
"url": s.URL,
|
||||
"user_code": s.UserCode,
|
||||
"message": s.Message,
|
||||
"account": s.Account,
|
||||
"created_at": s.CreatedAt.Unix(),
|
||||
"expires_at": s.ExpiresAt.Unix(),
|
||||
}
|
||||
if includeNonce {
|
||||
out["nonce"] = s.Nonce
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// luaTableArg decodes a Lua table argument into a Go map, reporting a clear error
|
||||
// when the caller passed something else (a common mistake that would otherwise
|
||||
// surface as a silent zero value).
|
||||
func luaTableArg(L *golua.State, idx int) (map[string]interface{}, error) {
|
||||
if L.Type(idx) != golua.LUA_TTABLE {
|
||||
return nil, fmt.Errorf("expected a table argument, got %s", normalizeString(L, idx))
|
||||
}
|
||||
var raw interface{}
|
||||
if err := luaToJSON(L, idx, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("argument must be a keyed table")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// readPluginAuthHosts reads `plugin.auth_hosts`, the login hosts a plugin needs.
|
||||
//
|
||||
// plugin.auth_hosts = { "auth.example.com", "accounts.google.com" }
|
||||
//
|
||||
// A list of bare hostnames — not URLs — because the allowlist matches on host,
|
||||
// and accepting full URLs would invite a plugin to smuggle a path or scheme that
|
||||
// the operator did not read. Entries are normalised so the granted and declared
|
||||
// forms always compare equal.
|
||||
func readPluginAuthHosts(L *golua.State) []string {
|
||||
raw, ok := pluginFieldValue(L, "auth_hosts")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
items, isList := raw.([]interface{})
|
||||
if !isList {
|
||||
if one, isTable := raw.(map[string]interface{}); isTable {
|
||||
if v, ok := one["hosts"].([]interface{}); ok {
|
||||
items = v
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
seen := map[string]bool{}
|
||||
for _, it := range items {
|
||||
s, ok := it.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
h := normalizeHost(s)
|
||||
if h == "" || seen[h] {
|
||||
continue
|
||||
}
|
||||
seen[h] = true
|
||||
out = append(out, h)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
457
internal/lua/plugin_login.go
Normal file
457
internal/lua/plugin_login.go
Normal 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)
|
||||
}
|
||||
294
internal/lua/plugin_login_test.go
Normal file
294
internal/lua/plugin_login_test.go
Normal 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("未知流程名应被拒")
|
||||
}
|
||||
}
|
||||
@ -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