diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go
index 65f01fb..36df805 100644
--- a/internal/gateway/plugins_api.go
+++ b/internal/gateway/plugins_api.go
@@ -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(),
+ })
+}
diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html
index 7c238ef..8fd7f82 100644
--- a/internal/gateway/ui/index.html
+++ b/internal/gateway/ui/index.html
@@ -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() {
${hooks} |
${plVisCell(j, p)} |
${plEpsCell(j, p)} |
+ ${plKernelCell(p)} |
${toggle}
@@ -4882,8 +4921,8 @@ function afterChainEdit() {
$("#tab-plugins").innerHTML = `
${t("plTitle")} (${(j.on_disk || []).length})
- | ${t("tName")} | ${t("plState")} | ${t("plStages")} | ${t("plVisibility")} | ${t("plEndpoints")} | |
- ${rows || `| ${t("plEmpty")} | `}
+ ${t("tName")} | ${t("plState")} | ${t("plStages")} | ${t("plVisibility")} | ${t("plEndpoints")} | ${t("plKernel")} | |
+ ${rows || ` | ${t("plEmpty")} | `}
${errList ? `${t("plHookErr")} ` : ""}
${t("plDir")} ${esc(j.plugin_dir)}
@@ -4897,7 +4936,21 @@ function afterChainEdit() {
`;
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() {
: `—`;
}
+ // 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 `—`;
+ 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
+ ? ` ${t("plKernelReq")}`
+ : "";
+ return `${label}${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 = `${esc(name)}${esc(String(e.message || e))} `;
+ 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"
+ ? `${t("plDangerDestructive")}`
+ : a.danger === "write"
+ ? `${t("plDangerWrite")}`
+ : `${t("plDangerRead")}`;
+ const asked = a.declared ? ` ${t("plKernelAsked")}` : "";
+ return ` |
+ |
+ ${esc(a.name)}${asked} ${esc(a.description || "")} |
+ ${tag} |
`;
+ })
+ .join("");
+ const declaredHosts = j.auth_hosts_declared || [];
+ const grantedHosts = new Set(j.auth_hosts_granted || []);
+ const hostRows = declaredHosts.length
+ ? declaredHosts
+ .map(
+ (h) => ` |
+ ${esc(h)} |
+ ${grantedHosts.has(h) ? `${t("plHostOk")}` : `${t("plHostPending")}`} |
`,
+ )
+ .join("")
+ : `| ${t("plNoAuthHosts")} |
`;
+ host.innerHTML = `
+ ${esc(name)} — ${t("plKernelTitle")}
+
${t("plKernelHint")}
+
+ | ${t("plAction")} | ${t("plDanger")} |
+ ${rows}
+
+
+
+
+ ${t("plAuthHosts")}
+
${t("plAuthHostsHint")}
+
+
`;
+ }
+
+ 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
diff --git a/internal/lua/kernel_api.go b/internal/lua/kernel_api.go
index 10945d5..9f6c457 100644
--- a/internal/lua/kernel_api.go
+++ b/internal/lua/kernel_api.go
@@ -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
+}
diff --git a/internal/lua/plugin_bridges.go b/internal/lua/plugin_bridges.go
index 61e908d..5443324 100644
--- a/internal/lua/plugin_bridges.go
+++ b/internal/lua/plugin_bridges.go
@@ -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
+}
diff --git a/internal/lua/plugin_login.go b/internal/lua/plugin_login.go
new file mode 100644
index 0000000..3102808
--- /dev/null
+++ b/internal/lua/plugin_login.go
@@ -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//...) 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)
+}
diff --git a/internal/lua/plugin_login_test.go b/internal/lua/plugin_login_test.go
new file mode 100644
index 0000000..7af9c7c
--- /dev/null
+++ b/internal/lua/plugin_login_test.go
@@ -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("未知流程名应被拒")
+ }
+}
diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go
index 0751932..6ef881b 100644
--- a/internal/lua/plugins.go
+++ b/internal/lua/plugins.go
@@ -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)