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

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