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

- - ${rows || ``}
${t("tName")}${t("plState")}${t("plStages")}${t("plVisibility")}${t("plEndpoints")}
${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")}

+
+ + ${rows} +
${t("plAction")}${t("plDanger")}
+

+

+
+

${t("plAuthHosts")}

+

${t("plAuthHostsHint")}

+
${hostRows}
+
`; + } + + 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)