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

@ -105,6 +105,30 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
}
}
// /api/plugins/{name}/kernel — the operator's view of, and edit surface for,
// which kernel actions a plugin may call, plus its declared login hosts.
if strings.HasSuffix(path, "/kernel") {
name := strings.TrimSuffix(path, "/kernel")
if name == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "plugin name required")
return
}
g.handlePluginKernel(w, r, name)
return
}
// /api/plugins/{name}/login — in-flight login sessions for the settings page
// (the page polls these while the operator completes a sign-in elsewhere).
if strings.HasSuffix(path, "/login") {
name := strings.TrimSuffix(path, "/login")
if name == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "plugin name required")
return
}
g.handlePluginLogin(w, r, name)
return
}
// /api/plugins/{name}/call/{fn}
if i := strings.Index(path, "/call/"); i >= 0 {
name := path[:i]
@ -617,3 +641,93 @@ func (g *Gateway) handlePluginVisibility(w http.ResponseWriter, r *http.Request,
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method+" is not allowed here")
}
}
// handlePluginKernel is the operator's surface for a plugin's kernel grants and
// declared login hosts.
//
// GET returns what the plugin DECLARED beside what was GRANTED. That pairing is
// the point of the endpoint: a plugin declaring `add_source` is a request, and
// the operator needs to see the request next to the current answer to decide.
func (g *Gateway) handlePluginKernel(w http.ResponseWriter, r *http.Request, name string) {
ps := g.core.Plugins()
if ps == nil {
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
return
}
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
switch r.Method {
case http.MethodGet:
perm := ps.PluginKernelPermission(name)
declared := []string(nil)
for _, v := range ps.KernelPermissions() {
if v.Plugin == name {
declared = v.Declared
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"plugin": name,
"actions": ps.KernelActionsView(name),
"declared": declared,
"granted": perm.Actions,
"all_read": perm.AllRead,
// Login hosts: declared vs granted, same request/privilege split.
"auth_hosts_declared": ps.PluginAuthHosts(name),
"auth_hosts_granted": ps.AuthHostsGranted()[name],
"vnc_enabled": ps.VNCEnabled(),
})
return
case http.MethodPut, http.MethodPost:
var body struct {
Actions []string `json:"actions"`
AllRead bool `json:"all_read"`
AuthHosts []string `json:"auth_hosts"`
VNC *bool `json:"vnc_enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if err := ps.SetPluginKernelPermission(name, body.Actions, body.AllRead); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.AuthHosts != nil {
if err := ps.GrantAuthHosts(name, body.AuthHosts); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
}
if body.VNC != nil {
ps.SetVNCEnabled(*body.VNC)
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
return
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET or PUT")
}
}
// handlePluginLogin lists a plugin's in-flight login sessions so the settings
// page can show "waiting for you to finish signing in" and the URL to open.
func (g *Gateway) handlePluginLogin(w http.ResponseWriter, r *http.Request, name string) {
ps := g.core.Plugins()
if ps == nil {
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
return
}
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"sessions": ps.ListLogins(name),
"vnc_enabled": ps.VNCEnabled(),
})
}

View File

@ -850,6 +850,25 @@
plVisibility: "可见性",
plEndpoints: "端点",
plKernel: "内核动作",
plKernelTitle: "内核动作权限",
plKernelHint: "勾选即授权。插件源码里申请(plugin.kernel)但未授权的动作会标红 —— 那通常意味着它的某个功能静默失效。",
plKernelAllRead: "只读全开",
plKernelN: "%d 项",
plKernelReq: "有申请",
plKernelAsked: "申请中",
plDangerRead: "只读",
plDangerWrite: "可写",
plDangerDestructive: "破坏性",
plAction: "动作",
plDanger: "危险级",
plSave: "保存",
plClose: "关闭",
plAuthHosts: "登录主机(出站白名单)",
plAuthHostsHint: "登录流程要访问的主机不是上游源,默认被出站白名单拒绝。勾选即批准;只能批准插件自己声明过的。",
plHostOk: "已批准",
plHostPending: "待批准",
plNoAuthHosts: "该插件未声明登录主机",
plAccessTitle: "访问控制",
plAccessHint: "填写允许访问的角色或密钥(@名称)。留空=仅 admin。填 public=任何人。指定 @密钥 时只有该密钥能访问,不受角色规则影响。",
plWholePlugin: "以下端点",
@ -1162,6 +1181,25 @@
plVisibility: "Visibility",
plEndpoints: "Endpoints",
plKernel: "Kernel actions",
plKernelTitle: "Kernel action permissions",
plKernelHint: "Tick to grant. An action a plugin declares in source but was not granted is flagged red - that usually means one of its features is silently dead.",
plKernelAllRead: "all read",
plKernelN: "%d granted",
plKernelReq: "requested",
plKernelAsked: "asked",
plDangerRead: "read",
plDangerWrite: "write",
plDangerDestructive: "destructive",
plAction: "Action",
plDanger: "Danger",
plSave: "Save",
plClose: "Close",
plAuthHosts: "Login hosts (outbound allowlist)",
plAuthHostsHint: "Login flows reach hosts that are not upstream sources, which the outbound allowlist blocks by default. Tick to approve; only hosts the plugin itself declared can be approved.",
plHostOk: "approved",
plHostPending: "pending",
plNoAuthHosts: "this plugin declared no login hosts",
plAccessTitle: "Access control",
plAccessHint: "Roles or keys (@name) allowed to reach each endpoint. Empty = admin only. public = anyone. Listing @keys replaces the role rules entirely.",
plWholePlugin: "endpoints below",
@ -4861,6 +4899,7 @@ function afterChainEdit() {
<td>${hooks}</td>
<td>${plVisCell(j, p)}</td>
<td>${plEpsCell(j, p)}</td>
<td data-plkernel="${escAttr(p.name)}">${plKernelCell(p)}</td>
<td style="white-space:nowrap">
<button class="small" onclick="editPlugin('${escAttr(p.name)}')">${t("plEdit")}</button>
${toggle}
@ -4882,8 +4921,8 @@ function afterChainEdit() {
$("#tab-plugins").innerHTML = `
<div class="card"><h2>${t("plTitle")} (${(j.on_disk || []).length})</h2>
<div class="tbl-wrap"><table><tr>
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th>${t("plVisibility")}</th><th>${t("plEndpoints")}</th><th></th>
</tr>${rows || `<tr><td colspan="6" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th>${t("plVisibility")}</th><th>${t("plEndpoints")}</th><th>${t("plKernel")}</th><th></th>
</tr>${rows || `<tr><td colspan="7" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
<div id="pl-access"></div>
${errList ? `<p class="muted" style="color:var(--danger)">${t("plHookErr")}</p><ul style="font-size:12px">${errList}</ul>` : ""}
<p class="muted">${t("plDir")} <code>${esc(j.plugin_dir)}</code></p>
@ -4897,7 +4936,21 @@ function afterChainEdit() {
<p><button onclick="installPlugin()">${t("plInstallBtn")}</button></p>
</div>`;
bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code");
window.__plNames = (j.on_disk || []).map((p) => p.name);
renderPluginAccess(j);
// Kernel grants load separately (one request per plugin) and then patch
// ONLY the affected cells. Re-calling renderPlugins() here would recurse:
// renderPlugins -> refreshKernelSummary -> renderPlugins.
refreshKernelSummary().then(() => {
window.__plNames.forEach((n) => {
const el = document.querySelector(`[data-plkernel="${CSS.escape(n)}"]`);
if (el) {
const p = (j.on_disk || []).find((x) => x.name === n);
if (p) el.innerHTML = plKernelCell(p);
}
});
bindKernelCells();
});
}
@ -4958,6 +5011,123 @@ function afterChainEdit() {
: `<span class="muted">—</span>`;
}
// plKernelCell shows how many kernel actions a plugin may call, and flags
// a DECLARED-but-not-granted request. The flag matters: a plugin that asks
// for add_source in its source and never gets it looks identical to one
// that never asked, until its feature silently doesn't work.
function plKernelCell(p) {
const k = (window.__plKernel || {})[p.name];
if (!k) return `<span class="muted">—</span>`;
const granted = (k.granted || []).length + (k.all_read ? 1 : 0);
const pending = (k.declared || []).filter((d) => !(k.granted || []).includes(d));
const label = k.all_read ? t("plKernelAllRead") : t("plKernelN").replace("%d", granted);
const warn = pending.length
? ` <span class="tag tag-red" title="${escAttr(pending.join(", "))}">${t("plKernelReq")}</span>`
: "";
return `<a href="#" onclick="openKernelEditor('${escAttr(p.name)}');return false">${label}</a>${warn}`;
}
// openKernelEditor renders the per-plugin kernel/permission panel into
// #pl-access below the table.
async function openKernelEditor(name) {
const host = $("#pl-access");
if (!host) return;
let j;
try {
j = await api("/api/plugins/" + encodeURIComponent(name) + "/kernel");
} catch (e) {
host.innerHTML = `<div class="card"><h2>${esc(name)}</h2><div class="muted">${esc(String(e.message || e))}</div></div>`;
return;
}
const dangerOrder = { read: 0, write: 1, destructive: 2 };
const acts = (j.actions || []).slice().sort((a, b) => {
const d = (dangerOrder[a.danger] ?? 9) - (dangerOrder[b.danger] ?? 9);
return d !== 0 ? d : a.name.localeCompare(b.name);
});
const granted = new Set(j.granted || []);
const rows = acts
.map((a) => {
const tag = a.danger === "destructive"
? `<span class="tag tag-red">${t("plDangerDestructive")}</span>`
: a.danger === "write"
? `<span class="tag">${t("plDangerWrite")}</span>`
: `<span class="tag tag-green">${t("plDangerRead")}</span>`;
const asked = a.declared ? ` <span class="tag tag-blue">${t("plKernelAsked")}</span>` : "";
return `<tr>
<td><input type="checkbox" data-ka="${escAttr(a.name)}" ${granted.has(a.name) ? "checked" : ""}></td>
<td><b>${esc(a.name)}</b>${asked}<div class="muted">${esc(a.description || "")}</div></td>
<td>${tag}</td></tr>`;
})
.join("");
const declaredHosts = j.auth_hosts_declared || [];
const grantedHosts = new Set(j.auth_hosts_granted || []);
const hostRows = declaredHosts.length
? declaredHosts
.map(
(h) => `<tr><td><input type="checkbox" data-ah="${escAttr(h)}" ${grantedHosts.has(h) ? "checked" : ""}></td>
<td><code>${esc(h)}</code></td>
<td>${grantedHosts.has(h) ? `<span class="tag tag-green">${t("plHostOk")}</span>` : `<span class="tag">${t("plHostPending")}</span>`}</td></tr>`,
)
.join("")
: `<tr><td colspan="3" class="empty">${t("plNoAuthHosts")}</td></tr>`;
host.innerHTML = `
<div class="card"><h2>${esc(name)} — ${t("plKernelTitle")}</h2>
<p class="muted">${t("plKernelHint")}</p>
<div class="tbl-wrap"><table>
<tr><th></th><th>${t("plAction")}</th><th>${t("plDanger")}</th></tr>
${rows}
</table></div>
<p><button onclick="saveKernelPerms('${escAttr(name)}')">${t("plSave")}</button>
<button onclick="document.getElementById('pl-access').innerHTML=''">${t("plClose")}</button></p>
</div>
<div class="card"><h2>${t("plAuthHosts")}</h2>
<p class="muted">${t("plAuthHostsHint")}</p>
<div class="tbl-wrap"><table>${hostRows}</table></div>
</div>`;
}
async function saveKernelPerms(name) {
const actions = [...document.querySelectorAll("[data-ka]")]
.filter((c) => c.checked)
.map((c) => c.getAttribute("data-ka"));
const auth_hosts = [...document.querySelectorAll("[data-ah]")]
.filter((c) => c.checked)
.map((c) => c.getAttribute("data-ah"));
try {
await api("/api/plugins/" + encodeURIComponent(name) + "/kernel", {
method: "PUT",
body: JSON.stringify({ actions, all_read: false, auth_hosts }),
});
await refreshKernelSummary();
await openKernelEditor(name);
} catch (e) {
alert(String(e.message || e));
}
}
// bindKernelCells is a no-op hook kept so the initial render and the
// post-summary patch share one code path; the cell uses an inline onclick,
// so there is nothing to re-bind. It exists to make that explicit rather
// than to leave a missing function.
function bindKernelCells() {}
// refreshKernelSummary loads every plugin's grant state so the table
// column can show it without a request per row.
async function refreshKernelSummary() {
const names = (window.__plNames || []);
const out = {};
await Promise.all(
names.map(async (n) => {
try {
out[n] = await api("/api/plugins/" + encodeURIComponent(n) + "/kernel");
} catch (e) {
/* a plugin that vanished mid-refresh simply has no summary */
}
}),
);
window.__plKernel = out;
}
// ---- visibility & endpoint access ----
//
// The plugin page is where an operator edits who can see what. The

View File

@ -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
}

View File

@ -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
}

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)
}

View 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("未知流程名应被拒")
}
}

View File

@ -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)