diff --git a/internal/core/channels_plugin_test.go b/internal/core/channels_plugin_test.go index 5aba278..e217e21 100644 --- a/internal/core/channels_plugin_test.go +++ b/internal/core/channels_plugin_test.go @@ -12,11 +12,10 @@ import ( // // 这条链路横跨本次所有新增能力,所以它是最有信息量的一个测试: // -// __llmsproxy_login 创建/轮询登录会话 -// __llmsproxy_secrets 凭据进保险库(不进 state) // __llmsproxy_kernel add_source 把平台注册为上游源 // 内核权限 默认拒绝,需运维授权 -// 登录主机授权 登录域不在源列表里,默认被出站白名单拒绝 +// 额外出站主机授权 登录域不在源列表里,默认被出站白名单拒绝(plugin.hosts) +// 登录会话 插件自持(plugin.state.sessions),内核无登录概念 func TestChannelsPluginEndToEnd(t *testing.T) { c := kernelTestCore(t) ps := c.Plugins() @@ -175,12 +174,12 @@ func TestChannelsPluginDeclaresItsNeeds(t *testing.T) { t.Errorf("应声明 %q, 实得 %v", want, declared) } } - // 登录主机声明(非源 host,需单独批准) - hosts := ps.PluginAuthHosts("channels") + // 额外出站主机声明(非源 host,需单独批准) + hosts := ps.PluginExtraHosts("channels") if len(hosts) == 0 { - t.Fatal("channels 应声明登录主机") + t.Fatal("channels 应声明额外出站主机") } - t.Logf("声明内核动作=%v 登录主机=%v", declared, hosts) + t.Logf("声明内核动作=%v 额外主机=%v", declared, hosts) } // TestChannelsPluginHandlersRequireStatus 是一个契约回归:插件的每个 handler diff --git a/internal/gateway/browser_proxy.go b/internal/gateway/browser_proxy.go deleted file mode 100644 index debf329..0000000 --- a/internal/gateway/browser_proxy.go +++ /dev/null @@ -1,110 +0,0 @@ -package gateway - -import ( - "net/http" - "net/http/httputil" - "net/url" - "strings" -) - -// 服务端浏览器 viewer 的反向代理(R4-f) -// -// 用户指明的场景:有些平台的登录会**重定向到本机回环地址**,用户机器上的浏览器 -// 收不到(那个 127.0.0.1 是用户自己的),所以必须在服务端跑浏览器。而那个浏览器 -// 需要给用户一个能操作的界面 —— 这就是本文件。 -// -// 架构:Xvfb(:N) ← chromium;x11vnc 把 :N 暴露成 VNC(:v);websockify 把 VNC 桥成 -// WebSocket 并顺带伺服 noVNC 静态文件(:w)。网关把 :w 反代到 -// /browser//... -// -// 为什么反代而不是让用户直连 :w: -// -// 1. **鉴权**。直连等于把一个"已经登录着用户第三方账号"的浏览器暴露在网络上, -// 任何能访问该端口的人都能接着用那个会话。反代让它继承网关的登录态与角色。 -// 2. **WebSocket**。noVNC 的核心是 WS 升级;httputil.ReverseProxy 从 Go 1.12 -// 起原生处理 Upgrade,自己写会把这块写错。 -// 3. **同源**。viewer 与网关同源,noVNC 的 WS 路径不需要跨域配置。 - -// browserMountPrefix 是 viewer 的挂载前缀。 -const browserMountPrefix = "/browser/" - -// isBrowserMount 判断请求是否属于 viewer。 -func isBrowserMount(p string) bool { return strings.HasPrefix(p, browserMountPrefix) } - -// handleBrowserMount 把 /browser//... 反代到该会话的 websockify。 -// -// 鉴权沿用网关的通用路径(Handler 外层已做 auth),因此只有已登录的运维能打开; -// 这里额外要求 admin 角色:能操作一个已登录浏览器的人等价于能操作被登录的账号。 -func (g *Gateway) handleBrowserMount(w http.ResponseWriter, r *http.Request) { - if reqRole(r.Context()) != "admin" { - writeError(w, http.StatusForbidden, "forbidden", "admin role required") - return - } - rest := strings.TrimPrefix(r.URL.Path, browserMountPrefix) - id := rest - if i := strings.Index(rest, "/"); i >= 0 { - id = rest[:i] - } - if id == "" { - writeError(w, http.StatusNotFound, "not_found", "browser session id required") - return - } - ps := g.core.Plugins() - if ps == nil { - writeError(w, http.StatusNotFound, "not_found", "no plugins loaded") - return - } - sess, ok := ps.GetBrowserSession(id) - if !ok { - // An expired session is torn down on read, so "not found" is the honest - // answer: the browser and its cookies are gone. - writeError(w, http.StatusNotFound, "not_found", "no such browser session (it may have expired)") - return - } - if sess.WSPort == 0 { - writeError(w, http.StatusInternalServerError, "internal", "browser session has no viewer port") - return - } - - target := &url.URL{Scheme: "http", Host: "127.0.0.1:" + itoa(sess.WSPort)} - proxy := httputil.NewSingleHostReverseProxy(target) - - // The session id is stripped from the path before proxying, so websockify - // sees the SAME paths it serves locally ("/vnc.html", "/websockify"). - // Without this the viewer's bundled JS would build a WS URL containing the - // gateway prefix and websockify would 404 the upgrade. - orig := r.URL.Path - prefixLen := len(browserMountPrefix) + len(id) - if prefixLen < len(orig) { - r.URL.Path = orig[prefixLen:] - } else { - r.URL.Path = "/" - } - r.URL.RawPath = "" - // Preserve the browser-facing Host so noVNC's relative URLs stay on the - // gateway origin. - r.Header.Set("X-Forwarded-Prefix", browserMountPrefix+id) - - // A failed upgrade is the common failure (a stale port after teardown), and - // the default proxy error page says nothing useful. - proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { - writeError(w, http.StatusBadGateway, "browser_unavailable", - "the browser viewer is not reachable: "+err.Error()) - } - proxy.ServeHTTP(w, r) -} - -// itoa is a tiny integer formatter for port numbers. -func itoa(n int) string { - if n == 0 { - return "0" - } - var buf [20]byte - i := len(buf) - for n > 0 { - i-- - buf[i] = byte('0' + n%10) - n /= 10 - } - return string(buf[i:]) -} diff --git a/internal/gateway/browser_proxy_test.go b/internal/gateway/browser_proxy_test.go deleted file mode 100644 index 8b4b359..0000000 --- a/internal/gateway/browser_proxy_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package gateway - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -// TestBrowserViewerIsProxiedAndAuthenticated 验证服务端浏览器 viewer 的反代。 -// -// viewer 一定要经网关反代而不是让用户直连 websockify 端口: -// - 直连等于把一个"已登录用户第三方账号"的浏览器暴露在网络上 -// - noVNC 的核心是 WebSocket 升级,反代由 httputil 原生处理 -// - 同源让 noVNC 的 WS 路径不需要跨域配置 -func TestBrowserViewerIsProxiedAndAuthenticated(t *testing.T) { - g := newTestGateway(t) - c := g.core - ps := c.Plugins() - if ps == nil { - t.Fatal("无 plugins") - } - ps.LoadSource("bp", `local plugin = { name = "bp", version = "1.0" } return plugin`) - ps.SetVNCEnabled(true) - sess, err := ps.StartBrowserSession("bp", "example", "about:blank", 0) - if err != nil { - t.Fatalf("启动浏览器会话: %v", err) - } - defer ps.StopBrowserSession(sess.ID) - time.Sleep(4 * time.Second) // 让 websockify 起来 - - ts := httptest.NewServer(g.Handler()) - defer ts.Close() - - // 不带 key → 401(viewer 继承网关鉴权) - // 关键:不要跟随重定向,否则拿到的是登录页(200)而不是真实判定 - noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }} - resp, err := noFollow.Get(ts.URL + sess.ViewURL) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - t.Logf("未鉴权访问: status=%d location=%q", resp.StatusCode, resp.Header.Get("Location")) - if resp.StatusCode == 200 { - t.Errorf("★ 未鉴权访问 viewer 竟然 200") - } - - // 带 admin key → 应拿到 noVNC 页面 - req, _ := http.NewRequest("GET", ts.URL+"/browser/"+sess.ID+"/vnc.html", nil) - req.Header.Set("Authorization", "Bearer sk-test") - resp, err = http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - buf := make([]byte, 4096) - n, _ := resp.Body.Read(buf) - body := string(buf[:n]) - if resp.StatusCode != 200 { - t.Fatalf("viewer 应 200, 实得 %d: %s", resp.StatusCode, body) - } - if !strings.Contains(strings.ToLower(body), "novnc") && !strings.Contains(body, "= 0 { @@ -643,7 +635,7 @@ func (g *Gateway) handlePluginVisibility(w http.ResponseWriter, r *http.Request, } // handlePluginKernel is the operator's surface for a plugin's kernel grants and -// declared login hosts. +// declared extra outbound 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 @@ -673,18 +665,18 @@ func (g *Gateway) handlePluginKernel(w http.ResponseWriter, r *http.Request, nam "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(), + // Extra outbound hosts: declared vs granted, same request/privilege + // split as kernel actions. What the plugin does with a host (login, + // claim quotas...) is plugin business — the kernel only bounds egress. + "hosts_declared": ps.PluginExtraHosts(name), + "hosts_granted": ps.ExtraHostsGranted()[name], }) 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"` + Actions []string `json:"actions"` + AllRead bool `json:"all_read"` + Hosts []string `json:"hosts"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) @@ -694,40 +686,15 @@ func (g *Gateway) handlePluginKernel(w http.ResponseWriter, r *http.Request, nam writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } - if body.AuthHosts != nil { - if err := ps.GrantAuthHosts(name, body.AuthHosts); err != nil { + if body.Hosts != nil { + if err := ps.GrantExtraHosts(name, body.Hosts); 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/server.go b/internal/gateway/server.go index f9241f1..0dcc5f6 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -260,11 +260,6 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { g.handleCustomMount(w, r) case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"): g.handlePluginsAPI(w, r) - case isBrowserMount(r.URL.Path): - // Server-side browser viewer (VNC-over-WebSocket via noVNC). Checked - // before the /api catch-alls because its path is outside them, and - // before the UI catch-all so it is not served as a static file. - g.handleBrowserMount(w, r) case r.URL.Path == "/api/chat": g.handleChat(w, r) case r.URL.Path == "/api/status": diff --git a/internal/lua/plugin_bridges.go b/internal/lua/plugin_bridges.go index 9dd5957..56387de 100644 --- a/internal/lua/plugin_bridges.go +++ b/internal/lua/plugin_bridges.go @@ -245,242 +245,6 @@ func readPluginKernelDeclared(L *golua.State) []string { 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.PushGoClosure(luaBrowserStart(ps, name)) - L.SetField(-2, "start_browser") - L.PushGoClosure(luaBrowserGet(ps, name)) - L.SetField(-2, "get_browser") - L.PushGoClosure(luaBrowserStop(ps, name)) - L.SetField(-2, "stop_browser") - L.SetGlobal(loginGlobal) -} - -// luaBrowserStart: start_browser({platform, start_url, callback_port}) -> -// -// ({view_url, id, callback_port}, nil) | (nil, err) -// -// The plugin gets a VIEW URL, never a browser-control handle. That is the -// deliberate boundary: a plugin that could drive the browser arbitrarily could -// read every tab's cookies, and the browser is logged into the OPERATOR's -// third-party accounts, not the plugin's own. -func luaBrowserStart(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.start_browser: " + err.Error()) - return 2 - } - port := 0 - if n, ok := asFloat(args["callback_port"]); ok { - port = int(n) - } - sess, err := ps.StartBrowserSession(name, asString(args["platform"]), - asString(args["start_url"]), port) - if err != nil { - L.PushNil() - L.PushString(err.Error()) - return 2 - } - pushGoValue(L, browserToLua(*sess)) - L.PushNil() - return 2 - } -} - -// luaBrowserGet: get_browser(id) -> (session, nil) | (nil, nil) -func luaBrowserGet(ps *Plugins, name string) golua.LuaGoFunction { - return func(L *golua.State) int { - id := L.ToString(1) - s, ok := ps.GetBrowserSession(id) - if !ok || s.Plugin != name { - L.PushNil() - L.PushNil() - return 2 - } - pushGoValue(L, browserToLua(*s)) - L.PushNil() - return 2 - } -} - -// luaBrowserStop: stop_browser(id) -> (true, nil) -func luaBrowserStop(ps *Plugins, name string) golua.LuaGoFunction { - return func(L *golua.State) int { - id := L.ToString(1) - if s, ok := ps.GetBrowserSession(id); ok && s.Plugin == name { - ps.StopBrowserSession(id) - } - L.PushBoolean(true) - L.PushNil() - return 2 - } -} - -// browserToLua renders a browser session for Lua. The CDP/debug ports are NOT -// included: the plugin receives what it needs to display the viewer and read the -// captured code, and nothing that would let it drive the browser. -func browserToLua(s BrowserSession) map[string]interface{} { - return map[string]interface{}{ - "id": s.ID, - "platform": s.Platform, - "view_url": s.ViewURL, - "callback_port": s.CallbackPort, - "captured_code": s.CapturedCode, - "state": s.CapturedState, - "error": s.Error, - "started_at": s.StartedAt.Unix(), - "expires_at": s.ExpiresAt.Unix(), - } -} - -// 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). @@ -498,46 +262,3 @@ func luaTableArg(L *golua.State, idx int) (map[string]interface{}, error) { } 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_browser.go b/internal/lua/plugin_browser.go deleted file mode 100644 index 6889513..0000000 --- a/internal/lua/plugin_browser.go +++ /dev/null @@ -1,388 +0,0 @@ -package lua - -import ( - "context" - "fmt" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "time" -) - -// Package lua — 服务端浏览器会话(R4-f,VNC 登录流程) -// -// 用户点明的场景:**有些平台的登录会重定向到本机回环地址**(OAuth loopback / -// RFC 8252 原生应用流程)。而: -// -// 浏览器在用户的机器上 → 重定向打到 127.0.0.1 是**用户的**回环 → 服务端收不到 code -// 浏览器在服务端 → 重定向打到 127.0.0.1 是**服务端的**回环 → 插件直接收到 code -// -// 后者不需要"提取浏览器里的凭据"——授权码会自己流到监听方。所以对回环类登录, -// 服务端浏览器不是权宜之计,而是**唯一自动化路径**。已实测验证(浏览器在服务端 -// 时 loopback 回调的 code 落到了服务端监听器)。 -// -// # 为什么仍然默认关闭 -// -// 这不是"多此一举",而是这套东西的风险确实最高:在服务端跑一个真实浏览器, -// 让它登录**用户的第三方账号**。所以: -// -// - 默认关闭,需运维在设置页显式开启 -// - 每个平台单独授权(不是"开了 VNC 就能登全部") -// - 浏览器与 X 服务跑在独立进程组,插件卸载/会话结束即回收 -// - **不把浏览器的 CDP 端口暴露给插件 Lua**:插件拿到的是"会话 id + 打开 URL", -// 不是任意控制浏览器的能力。否则一个插件就能读用户所有标签页的 cookie。 -// -// # 与 plan 的关系 -// -// 原计划把 VNC 定为"逃生舱、默认关闭"。用户指出回环场景后,它仍是逃生舱 -// (默认关闭),但**不再是"最后手段"**:对回环类它是首选且唯一可行的流程。 - -// browserSessionTTL bounds one browser session. A server-side browser holding a -// logged-in third-party session must not outlive the login attempt. -const browserSessionTTL = 20 * time.Minute - -// BrowserSession is one server-side browser exposed for interactive login. -type BrowserSession struct { - ID string `json:"id"` - Plugin string `json:"plugin"` - Platform string `json:"platform"` - // ViewURL is where the operator opens the VNC viewer (noVNC). It is served - // by the gateway reverse-proxying this session's websockify port, so it - // inherits the gateway's auth and the VNC port is never exposed directly. - ViewURL string `json:"view_url"` - // VNCPort / Display are internal; exposed for diagnostics only. - VNCPort int `json:"vnc_port"` - Display string `json:"display"` - // WSPort is the loopback port where noVNC's static files AND its WebSocket - // live (websockify serves both). The gateway reverse-proxies it so the - // viewer is reachable through the gateway's own auth instead of being - // exposed on the network. - WSPort int `json:"-"` - // CallbackPort is the loopback port the login flow will be redirected to. - // The operator never needs it, but it is the whole point of running the - // browser server-side, so it is surfaced for troubleshooting. - CallbackPort int `json:"callback_port"` - StartedAt time.Time `json:"started_at"` - ExpiresAt time.Time `json:"expires_at"` - // CapturedCode is the authorization code that arrived on the loopback - // listener (empty until the flow completes). - CapturedCode string `json:"captured_code,omitempty"` - CapturedState string `json:"captured_state,omitempty"` - Error string `json:"error,omitempty"` - done chan struct{} -} - -// browserProcs holds the OS processes for one session so teardown is one place. -type browserProcs struct { - xvfb *exec.Cmd - chromium *exec.Cmd - x11vnc *exec.Cmd - ws *exec.Cmd - cbSrv *http.Server - profDir string -} - -// browserSupervisor owns every live browser session. -type browserSupervisor struct { - mu sync.Mutex - sessions map[string]*browserSession -} - -type browserSession struct { - info BrowserSession - procs browserProcs - mu sync.Mutex - done chan struct{} -} - -func newBrowserSupervisor() *browserSupervisor { - return &browserSupervisor{sessions: map[string]*browserSession{}} -} - -func (ps *Plugins) browsers() *browserSupervisor { - ps.browserOnce.Do(func() { - if ps.browserStore == nil { - ps.browserStore = newBrowserSupervisor() - } - }) - return ps.browserStore -} - -// loopbackCallback is what the loopback listener captured. -type loopbackCallback struct { - code string - state string - err string -} - -// StartBrowserSession launches a server-side browser for one login. -// -// It is deliberately a *lot* of moving parts (Xvfb + chromium + VNC + noVNC + -// loopback listener) in one function rather than spread out, because the failure -// modes are order-dependent: a leftover Xvfb from a crashed session makes the -// next chromium fail in a way that looks like a browser bug. -func (ps *Plugins) StartBrowserSession(plugin, platform, startURL string, callbackPort int) (*BrowserSession, error) { - if !ps.VNCEnabled() { - return nil, fmt.Errorf("server-side browser login is disabled") - } - if ps.find(plugin) == nil { - return nil, fmt.Errorf("plugin %s not found", plugin) - } - if startURL == "" { - return nil, fmt.Errorf("the browser flow needs a start URL") - } - if callbackPort == 0 { - // Pick a free loopback port for the OAuth redirect to land on. - p, err := freeLoopbackPort() - if err != nil { - return nil, err - } - callbackPort = p - } - - display := fmt.Sprintf(":%d", 90+len(ps.browsers().sessions)%10) - vncPort, err := freeLoopbackPort() - if err != nil { - return nil, err - } - wsPort, err := freeLoopbackPort() - if err != nil { - return nil, err - } - profDir, err := os.MkdirTemp("", "llmsproxy-browser-*") - if err != nil { - return nil, err - } - if err := os.Chmod(profDir, 0o700); err != nil { - _ = os.RemoveAll(profDir) - return nil, err - } - - sess := &browserSession{ - info: BrowserSession{ - ID: newLoginID(), // same unguessable-id generator - Plugin: plugin, - Platform: platform, - VNCPort: vncPort, - WSPort: wsPort, - Display: display, - CallbackPort: callbackPort, - StartedAt: time.Now(), - ExpiresAt: time.Now().Add(browserSessionTTL), - }, - procs: browserProcs{profDir: profDir}, - done: make(chan struct{}), - } - - if err := ps.launchBrowserStack(sess, startURL, callbackPort, wsPort, vncPort, display, profDir); err != nil { - sess.teardown() - return nil, err - } - - ps.browsers().mu.Lock() - ps.browsers().sessions[sess.info.ID] = sess - ps.browsers().mu.Unlock() - - // The viewer is mounted under the plugin's own namespace so it inherits the - // gateway's authentication. Exposing VNC directly would hand anyone who can - // reach the port a logged-in browser. - // A relative query so noVNC's bundled JS connects its WebSocket back through - // THIS path (the gateway proxy) rather than guessing a host:port. - sess.info.ViewURL = fmt.Sprintf("/browser/%s/vnc.html?autoconnect=1&path=browser/%s/websockify", - sess.info.ID, sess.info.ID) - out := sess.info - return &out, nil -} - -// launchBrowserStack starts the five processes and wires the loopback capture. -func (ps *Plugins) launchBrowserStack(sess *browserSession, startURL string, callbackPort, wsPort, vncPort int, display, profDir string) error { - // 1. Virtual display. - sess.procs.xvfb = exec.Command("Xvfb", display, "-screen", "0", "1280x800x24", "-nolisten", "tcp") - sess.procs.xvfb.Stdout, sess.procs.xvfb.Stderr = nil, nil - if err := sess.procs.xvfb.Start(); err != nil { - return fmt.Errorf("start Xvfb (is it installed?): %w", err) - } - // Xvfb needs a moment before the display exists. - if err := waitForDisplay(display, 5*time.Second); err != nil { - return err - } - - // 2. Loopback callback listener — the reason this whole thing exists. - cb := make(chan loopbackCallback, 1) - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - cb <- loopbackCallback{code: q.Get("code"), state: q.Get("state"), err: q.Get("error")} - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, "

Login received. You can close this tab.

") - }) - srv := &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", callbackPort), Handler: mux} - ln, err := net.Listen("tcp", srv.Addr) - if err != nil { - return fmt.Errorf("bind loopback callback port %d: %w", callbackPort, err) - } - sess.procs.cbSrv = srv - go func() { _ = srv.Serve(ln) }() - - // Capture the callback into the session record. - go func() { - select { - case c := <-cb: - sess.mu.Lock() - sess.info.CapturedCode, sess.info.CapturedState, sess.info.Error = c.code, c.state, c.err - sess.mu.Unlock() - case <-sess.done: - } - }() - - // 3. Real browser on the virtual display. - args := []string{ - "--no-sandbox", // the container has no usable sandbox; see the security note - "--disable-dev-shm-usage", - "--user-data-dir=" + profDir, - "--no-first-run", - "--no-default-browser-check", - "--window-size=1280,800", - startURL, - } - chromium, err := findChromium() - if err != nil { - return err - } - sess.procs.chromium = exec.Command(chromium, args...) - sess.procs.chromium.Env = append(os.Environ(), "DISPLAY="+display) - if err := sess.procs.chromium.Start(); err != nil { - return fmt.Errorf("start chromium: %w", err) - } - - // 4. VNC server on the same display. - sess.procs.x11vnc = exec.Command("x11vnc", "-display", display, - "-rfbport", fmt.Sprint(vncPort), "-localhost", "-nopw", "-forever", "-shared", "-quiet") - if err := sess.procs.x11vnc.Start(); err != nil { - return fmt.Errorf("start x11vnc: %w", err) - } - - // 5. websockify bridges the browser's WebSocket to raw VNC for noVNC. - sess.procs.ws = exec.Command("websockify", "--web", "/usr/share/novnc", - fmt.Sprint(wsPort), fmt.Sprintf("127.0.0.1:%d", vncPort)) - if err := sess.procs.ws.Start(); err != nil { - return fmt.Errorf("start websockify: %w", err) - } - - return nil -} - -// teardown kills every process and removes the profile (which holds the session -// cookies for the logged-in account). -func (s *browserSession) teardown() { - func() { - defer func() { _ = recover() }() - close(s.done) - }() - if s.procs.cbSrv != nil { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - _ = s.procs.cbSrv.Shutdown(ctx) - cancel() - } - for _, c := range []*exec.Cmd{s.procs.ws, s.procs.x11vnc, s.procs.chromium, s.procs.xvfb} { - if c == nil || c.Process == nil { - continue - } - _ = c.Process.Kill() - _, _ = c.Process.Wait() - } - if s.procs.profDir != "" { - // The profile holds live third-party session cookies: removing it is - // part of the security contract, not housekeeping. - _ = os.RemoveAll(s.procs.profDir) - } -} - -// GetBrowserSession returns a copy for the UI. -func (ps *Plugins) GetBrowserSession(id string) (*BrowserSession, bool) { - bs := ps.browsers() - bs.mu.Lock() - s, ok := bs.sessions[id] - bs.mu.Unlock() - if !ok { - return nil, false - } - s.mu.Lock() - cp := s.info - s.mu.Unlock() - // Expiry applied on read, same as login sessions. - if time.Now().After(cp.ExpiresAt) { - ps.StopBrowserSession(id) - cp.Error = "the browser session timed out" - } - return &cp, true -} - -// StopBrowserSession tears one session down and forgets it. -func (ps *Plugins) StopBrowserSession(id string) { - bs := ps.browsers() - bs.mu.Lock() - s, ok := bs.sessions[id] - if ok { - delete(bs.sessions, id) - } - bs.mu.Unlock() - if ok { - s.teardown() - } -} - -// StopBrowserSessionsFor tears down every session a plugin owns (unload path). -func (ps *Plugins) StopBrowserSessionsFor(plugin string) int { - bs := ps.browsers() - bs.mu.Lock() - var victims []*browserSession - for id, s := range bs.sessions { - if s.info.Plugin == plugin { - victims = append(victims, s) - delete(bs.sessions, id) - } - } - bs.mu.Unlock() - for _, s := range victims { - s.teardown() - } - return len(victims) -} - -// ---- helpers ---- - -func findChromium() (string, error) { - for _, name := range []string{"chromium", "chromium-browser", "google-chrome"} { - if p, err := exec.LookPath(name); err == nil { - return p, nil - } - } - return "", fmt.Errorf("no chromium found on PATH; the server-side browser flow needs one") -} - -func freeLoopbackPort() (int, error) { - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return 0, err - } - defer l.Close() - return l.Addr().(*net.TCPAddr).Port, nil -} - -func waitForDisplay(display string, timeout time.Duration) error { - // Xvfb does not signal readiness; probing the unix socket is the reliable way. - sock := filepath.Join("/tmp/.X11-unix", "X"+strings.TrimPrefix(display, ":")) - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if _, err := os.Stat(sock); err == nil { - return nil - } - time.Sleep(50 * time.Millisecond) - } - return fmt.Errorf("display %s did not come up within %s", display, timeout) -} diff --git a/internal/lua/plugin_browser_test.go b/internal/lua/plugin_browser_test.go deleted file mode 100644 index 5f82fec..0000000 --- a/internal/lua/plugin_browser_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package lua - -import "testing" - -// TestServerBrowserFlowThroughPlugin 是 VNC 登录流程的端到端断言。 -// -// 背景(用户指明的场景):有些平台把授权重定向到 127.0.0.1。用户机器上的浏览器 -// 收不到(那个回环是**用户自己的**),所以浏览器必须跑在服务端 —— 此时重定向才会 -// 落到本进程的监听器上(已实测验证)。 -// -// 用户要求:这个浏览器窗口应**内嵌在插件对应的页签下**,所以插件拿到的是 -// view_url(iframe src),而不是让人跳出去开新窗口。 -func TestServerBrowserFlowThroughPlugin(t *testing.T) { - _, ps, _ := runtimePlugins(t) - src, _ := ReadBundledPlugin("channels") - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - // 默认关闭时:应给出明确提示而不是假装成功 - out, err := ps.InvokeHandler("channels", "start_login", &handlerRequest{ - Method: "POST", Body: `{"platform":"loopbackish"}`, - }) - if err != nil { - t.Fatal(err) - } - m := out.JSON.(map[string]interface{}) - // 默认关闭时在 begin 阶段就被拒(更早、更明确),而不是等到起浏览器才失败 - if m["error"] == nil && m["browser_error"] == nil { - t.Fatalf("默认关闭 VNC 时应被拒, 实得 %#v", m) - } - t.Logf("默认关闭时: error=%v browser_error=%v", m["error"], m["browser_error"]) - - // 开启后:应返回 view_url - ps.SetVNCEnabled(true) - out, err = ps.InvokeHandler("channels", "start_login", &handlerRequest{ - Method: "POST", Body: `{"platform":"loopbackish"}`, - }) - if err != nil { - t.Fatal(err) - } - m = out.JSON.(map[string]interface{}) - if m["browser_error"] != nil { - t.Fatalf("开启后仍失败: %v", m["browser_error"]) - } - vu, _ := m["view_url"].(string) - if vu == "" { - t.Fatal("应返回 view_url(供页签内嵌)") - } - t.Logf("★ 可内嵌的 view_url: %s", vu) - // 非回环平台不应起浏览器(等于白开一个高风险的浏览器) - out, _ = ps.InvokeHandler("channels", "start_login", &handlerRequest{ - Method: "POST", Body: `{"platform":"example"}`, - }) - if m2 := out.JSON.(map[string]interface{}); m2["view_url"] != nil { - t.Error("非回环平台不该起服务端浏览器") - } -} diff --git a/internal/lua/plugin_login.go b/internal/lua/plugin_login.go deleted file mode 100644 index 3102808..0000000 --- a/internal/lua/plugin_login.go +++ /dev/null @@ -1,457 +0,0 @@ -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 deleted file mode 100644 index 7af9c7c..0000000 --- a/internal/lua/plugin_login_test.go +++ /dev/null @@ -1,294 +0,0 @@ -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/plugin_outbound.go b/internal/lua/plugin_outbound.go new file mode 100644 index 0000000..edac2a6 --- /dev/null +++ b/internal/lua/plugin_outbound.go @@ -0,0 +1,152 @@ +package lua + +import ( + "fmt" + "sort" + "strings" + + golua "github.com/aarzilli/golua/lua" +) + +// Package lua — 插件额外出站主机的声明与批准 +// +// 内核的职责里有一条是"维护源"。源的 host 天然进出站白名单。但有些插件需要去 +// **源以外**的 host(比如一个账号平台的门户、领取额度的接口),这些 host 默认 +// 被白名单拒绝 —— 这是正确的默认:白名单的意义就是"插件只能去它该去的地方"。 +// +// 所以给出一个**通用**机制(不含任何"登录"概念——登录是插件自己的事): +// +// 插件在源码里声明 plugin.hosts = { "portal.example.com", ... } ← 请求 +// 运维在设置页逐个批准 ← 授权 +// +// 声明是请求,批准才是授权。为什么不给个"允许任意 host"的开关:一个能访问任意 +// host 的插件等于没有白名单。要求逐个列出,运维才有机会看出"这个插件为什么要 +// 访问一个我没听说过的域名"。 +// +// 内核在这里**只做白名单加减**,不认识这些 host 是用来登录、领额度还是别的 —— +// 那是插件的业务。 + +// PluginExtraHosts returns the extra outbound hosts a plugin declared. +func (ps *Plugins) PluginExtraHosts(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.ExtraHosts...) + } + } + return nil +} + +// GrantExtraHosts approves a set of outbound 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) GrantExtraHosts(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.PluginExtraHosts(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 outbound host %q", plugin, hn) + } + clean = append(clean, hn) + } + sort.Strings(clean) + ps.mu.Lock() + if ps.extraHosts == nil { + ps.extraHosts = map[string][]string{} + } + if len(clean) == 0 { + delete(ps.extraHosts, plugin) + } else { + ps.extraHosts[plugin] = clean + } + ps.rebuildOutboundAllowlistLocked() + ps.saveKernelPermissionsLocked() + ps.mu.Unlock() + return nil +} + +// ExtraHostsGranted returns the approved hosts for each plugin (for the UI). +func (ps *Plugins) ExtraHostsGranted() map[string][]string { + ps.mu.RLock() + defer ps.mu.RUnlock() + out := make(map[string][]string, len(ps.extraHosts)) + for k, v := range ps.extraHosts { + out[k] = append([]string(nil), v...) + } + return out +} + +// normalizeHost lowercases a host and strips a port, so "Portal.Example.com:443" +// and "portal.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 extra +// 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.extraHosts { + hosts = append(hosts, granted...) + } + ps.allowHTTP = newHTTPAllowlist(hosts) +} + +// readPluginExtraHosts reads `plugin.hosts`, the outbound hosts a plugin needs +// beyond the operator-configured sources. +// +// plugin.hosts = { "portal.example.com", "api.partner.example" } +// +// Bare hostnames, not URLs: the allowlist matches on host, and accepting a full +// URL would invite a plugin to smuggle a path or scheme the operator did not +// read. Entries are normalised so declared and granted forms compare equal. +func readPluginExtraHosts(L *golua.State) []string { + raw, ok := pluginFieldValue(L, "hosts") + if !ok { + return nil + } + items, isList := raw.([]interface{}) + if !isList { + 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_unload_test.go b/internal/lua/plugin_unload_test.go new file mode 100644 index 0000000..9a03a1e --- /dev/null +++ b/internal/lua/plugin_unload_test.go @@ -0,0 +1,52 @@ +package lua + +import ( + "os/exec" + "testing" + "time" +) + +// TestOnUnloadReapsPluginProcesses 验收通用卸载钩子。 +// +// 内核职责是"调度插件"——卸载是调度的一部分,而插件可能持有外部资源(进程、 +// 文件句柄、远端会话)。内核不知道也不需要知道这些资源是什么;它只要在关闭 +// 状态前给插件一次自清理的机会(on_unload),资源归属就始终在插件侧。 +// 本测试用"插件起一个进程、卸载时杀掉"来证明这条链路真的闭合。 +func TestOnUnloadReapsPluginProcesses(t *testing.T) { + _, ps, _ := runtimePlugins(t) + code := ` +local plugin = { name = "reaper", version = "1.0" } +plugin.exports = { "touch" } +local pid = nil +function plugin.touch() + local h = io.popen("sh -c 'sleep 300 & echo $!'") + pid = h:read("*l"); h:close() + return { status = 200, json = { pid = pid } } +end +function plugin.on_unload() + if pid then os.execute("kill -9 " .. pid .. " 2>/dev/null") end +end +return plugin` + if err := ps.LoadSource("reaper", code); err != nil { + t.Fatal(err) + } + out, err := ps.Call("reaper", "touch", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + m := out.(map[string]interface{}) + js := m["json"].(map[string]interface{}) + pid := js["pid"].(string) + time.Sleep(300 * time.Millisecond) + if err := exec.Command("kill", "-0", pid).Run(); err != nil { + t.Fatalf("进程应存活: %v", err) + } + if err := ps.Unload("reaper"); err != nil { + t.Fatalf("unload: %v", err) + } + time.Sleep(500 * time.Millisecond) + if err := exec.Command("kill", "-0", pid).Run(); err == nil { + exec.Command("kill", "-9", pid).Run() + t.Fatal("★ unload 后进程仍存活 —— on_unload 未被调用") + } +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index b6f650c..88f4b0a 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -244,9 +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 + // ExtraHosts lists outbound hosts this plugin declared in its own source + // (`plugin.hosts`) beyond the operator-configured sources. A REQUEST, not a + // grant: the operator approves each one in the settings page. + ExtraHosts []string // KernelDeclared lists the kernel actions this plugin asks for in its own // source (`plugin.kernel = {...}`). It is a REQUEST, shown to the operator @@ -311,24 +312,12 @@ 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 - // browserStore owns server-side browser sessions (plugin_browser.go). Lazy - // for the same reason as loginStore. - browserStore *browserSupervisor - browserOnce 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 + // extraHosts is the per-plugin set of APPROVED extra outbound hosts. Separate + // from what a plugin declared (Plugin.ExtraHosts): a declaration is a request. + extraHosts map[string][]string // save coalesces state writes into one background goroutine. // // A hook must NOT write synchronously: the billing plugin mutates state on @@ -541,8 +530,8 @@ func (ps *Plugins) DisableStatePersistence() { ps.stateDirDisabled = true } func (ps *Plugins) SetOutboundHosts(hosts []string) { ps.mu.Lock() defer ps.mu.Unlock() - // Remembered so a later auth-host grant can rebuild the allowlist as - // sources ∪ granted-login-hosts instead of losing the source half. + // Remembered so a later extra-host grant can rebuild the allowlist as + // sources ∪ granted-extra-hosts instead of losing the source half. ps.sourceHosts = append([]string(nil), hosts...) ps.rebuildOutboundAllowlistLocked() } @@ -660,9 +649,6 @@ 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() @@ -804,11 +790,11 @@ 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) + // ---- declared extra hosts ---- + // Some plugins need hosts that are not sources (account portals, claim + // endpoints), which the outbound allowlist blocks by default. The plugin + // declares what it needs; the operator approves each host. See plugin_outbound.go. + p.ExtraHosts = readPluginExtraHosts(w.L) w.L.SetTop(0) // ---- http endpoints ---- @@ -1891,8 +1877,27 @@ func (ps *Plugins) Unload(name string) error { ps.rebuild() return nil } + // Lifecycle hook: if the plugin exported on_unload, call it BEFORE closing + // the state. This is generic plugin lifecycle (part of "调度插件"), not any + // feature: a plugin that spawned OS processes or holds other external + // resources is the one that knows how to release them, and Unload is the + // only guaranteed point where it still can. Best-effort by design — a + // hook that errors must not block an unload the operator explicitly asked + // for (a wedged plugin has its own path above). p.mu.Lock() if p.state != nil && p.state.L != nil { + p.state.L.GetGlobal(pluginGlobal) + if L := p.state.L; L.Type(-1) == golua.LUA_TTABLE { + L.GetField(-1, "on_unload") + if L.Type(-1) == golua.LUA_TFUNCTION { + if err := callFnOn(L, "on_unload"); err != nil { + log.Printf("[plugins] %s: on_unload: %v", name, err) + } + } + L.Pop(2) // adapter/plugin table + field remnant + } else { + L.Pop(1) + } p.state.L.Close() p.state = nil } diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index e31f0da..42e1a26 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -15,7 +15,7 @@ -- ------------------------ -- 内核提供三样基础设施,插件自己不实现这些: -- --- __llmsproxy_login 登录会话生命周期(create/poll/resolve) +-- (登录会话不再由内核提供 —— 见下) -- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读) -- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…) -- @@ -31,8 +31,14 @@ -- reset_health 上游恢复后清冷却 -- status 面板显示运行状态 -- --- 登录主机也需批准(plugin.auth_hosts):登录页所在的域名不是上游源, --- 默认会被出站白名单拒绝——这是刻意的。 +-- 额外出站主机需批准(plugin.hosts):登录页所在的域名不是上游源,默认会被 +-- 出站白名单拒绝——这是刻意的,声明是请求、批准才是授权。 +-- +-- 登录会话归本插件自持(内核无"登录"概念):会话表在 plugin.state.sessions, +-- 带 nonce 与过期时间;浏览器栈(Xvfb/chromium/x11vnc/websockify)由本插件用 +-- os.execute 直接拉起并记录 pid,插件卸载/会话结束时自己回收。 +-- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化流程(轮询/回调) +-- → ③ VNC 无头浏览器兜底(无浏览器系统 / 自动化也无法完成的场景)。 local plugin = { name = "channels", @@ -45,7 +51,7 @@ local plugin = { plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" } -- 申请的登录主机(出站白名单)。内核里**不含**任何厂商名——这些只存在于插件。 -plugin.auth_hosts = { "auth.example.com", "accounts.google.com" } +plugin.hosts = { "auth.example.com", "accounts.google.com" } -- ============================================================================ -- 平台表 @@ -147,56 +153,166 @@ end -- -- 返回 {url=…, session=…} 给页面显示"请在自己的浏览器打开这个地址"。 -- 登录完成后用户浏览器会回到本插件(callback 流程)或本插件轮询到结果 --- (link-poll 流程)。 + +-- ============================================================================ +-- 登录会话(插件自持)与服务端浏览器栈 +-- ============================================================================ + +-- 登录会话放 plugin.state.sessions:内核没有"登录"概念,这正是插件该自己干的 +-- 事。表结构 { id, platform, flow, url, user_code, nonce, state, account, +-- created_at, expires_at }。nonce 只在 begin 响应里出现一次,存侧不外泄。 +local SESSION_TTL = 900 -- 15 分钟,过期会话在读取时惰性清理 + +local function newToken() + -- 用 hmac 自己跟自己造一个不可猜的 id(内核给过 sha256_hex) + return sha256_hex(tostring(os.time()) .. math.random(1e12) .. (plugin.name or "channels")) +end + +local function sessions() + if not plugin.state.sessions then plugin.state.sessions = {} end + return plugin.state.sessions +end + +-- sweepSessions 惰性清理过期/终态会话(无后台定时器,读取时顺带做) +local function sweepSessions() + local now = os.time and os.time() or 0 + for id, s in pairs(sessions()) do + if s.expires_at and s.expires_at < now then + sessions()[id] = nil + end + end +end + +-- ---- 服务端浏览器栈(VNC 兜底用)---- +-- 每个会话一组进程:Xvfb + chromium + x11vnc + websockify。pid 记在 +-- plugin.state.browser.pids,插件卸载或会话结束时 kill。 +-- 绑定地址默认 127.0.0.1(同机操作);远程运维可把 settings.vnc_bind 设成 +-- "0.0.0.0" 并依赖 x11vnc 随机会话密码(不设密码的裸 VNC 永远不会出现)。 + +local function browserStack() + if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end + return plugin.state.browser +end + +local function reapBrowser() + local b = browserStack() + for _, pid in ipairs(b.pids or {}) do + os.execute("kill -9 " .. tostring(pid) .. " 2>/dev/null") + end + b.pids = {} + b.session = nil +end + +-- spawnBrowser 拉起一组服务端浏览器。返回 view_url(给 iframe 内嵌)或 nil, err。 +-- start_url 是浏览器要打开的登录页;redirect_host 是拼 redirect_uri 用的主机名 +-- (浏览器在服务端,127.0.0.1 就是服务端自己的回环 —— 回调直接命中网关)。 +local function spawnBrowser(start_url, callback_path) + local b = browserStack() + if b.session then return nil, "已有浏览器会话在跑,请先停止" end + b.seq = b.seq + 1 + local disp = 90 + (b.seq % 10) + local vport = 59100 + (b.seq % 100) + local wport = 60100 + (b.seq % 100) + local bind = (plugin.state.settings and plugin.state.settings.vnc_bind) or "127.0.0.1" + -- 会话密码:bind 到非回环时是唯一的防线,随机生成、只在响应里给一次 + local pass = sha256_hex(newToken()):sub(1, 16) + -- 端口占位换成网关回环(浏览器在服务端,回调直接命中网关挂载点) + local url = start_url:gsub("redirect_uri=127%.0%.0%.1:%%d+", "redirect_uri=127.0.0.1:8081" .. callback_path) + + local cmds = { + ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp & echo $!"):format(disp), + ("chromium --no-sandbox --disable-dev-shm-usage --user-data-dir=/tmp/llmsproxy-ch-%d --no-first-run --window-size=1280,800 --display=:%d '%s' & echo $!"):format(b.seq, disp, url), + ("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet & echo $!"):format(disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass), + ("websockify --web /usr/share/novnc %s:%d 127.0.0.1:%d & echo $!"):format(bind, wport, vport), + } + local pids = {} + for _, c in ipairs(cmds) do + local h = io.popen(c) + local pid = h:read("*l") + h:close() + if not pid or pid == "" then + reapBrowser() + return nil, "进程启动失败: " .. c:match("^(%S+)") .. "(缺依赖?运行 deploy 脚本一键安装)" + end + pids[#pids+1] = pid + end + -- 给 X/chromium 一点起进程时间;不等待就绪探针(保持插件逻辑简单,失败可重试) + os.execute("sleep 2") + b.pids = pids + b.session = { display = disp, vnc = vport, ws = wport, pass = pass } + -- view_url 指向 websockify 伺服的 noVNC;同机直接 127.0.0.1,远程用 settings.vnc_host 覆盖 + local host = (plugin.state.settings and plugin.state.settings.vnc_host) or "127.0.0.1" + return { + view_url = ("http://%s:%d/vnc.html?autoconnect=1&host=%s&port=%d&password=%s"):format(host, wport, host, wport, pass), + password = pass, + } +end + +-- ============================================================================ +-- 登录流程:优先级阶梯(① 本机浏览器 → ② 自动化 → ③ VNC 兜底) +-- ============================================================================ + +-- start_login 按优先级阶梯响应: +-- ① 本机浏览器:直接返回登录 URL(绝大多数场景到此为止 —— 平台重定向打在 +-- 用户自己的机器上,插件轮询/收回调拿凭据) +-- ② 自动化:link-poll/callback 流程本身就是自动的(轮询 + nonce 回调), +-- 无需用户之外的动作 +-- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、 +-- 回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。 function plugin.start_login(req) local args = bodyOf(req) local plat = platformById(args and args.platform) if not plat then return { status = 400, json = { error = "未知平台: " .. tostring(args and args.platform) } } end - local s, err = __llmsproxy_login.begin({ + sweepSessions() + local now = os.time and os.time() or 0 + local sess = { + id = newToken():sub(1, 24), platform = plat.id, flow = plat.flow, url = plat.login_url, - }) - if err then return { status = 400, json = { error = err } } end - logEvent("开始登录 " .. plat.name) + nonce = newToken():sub(1, 32), + state = "pending", + created_at = now, + expires_at = now + SESSION_TTL, + } + sessions()[sess.id] = sess + logEvent("开始登录 " .. plat.name .. " (" .. plat.flow .. ")") - -- 回环类平台:必须在**服务端**开浏览器。 - -- 理由:授权重定向打到 127.0.0.1,用户机器上的那个是**用户自己的**回环, - -- 服务端收不到 code。只有浏览器跑在服务端,重定向才落到本进程的监听器上。 - local view_url + -- 响应里只带一次 nonce(回调校验要用),存侧不回显 + local out = { + session = { id = sess.id, platform = sess.platform, flow = sess.flow, + state = sess.state, nonce = sess.nonce, expires_at = sess.expires_at }, + platform = plat.id, + } if plat.flow == "vnc" then - -- 先建一个回环监听会话(拿到 callback_port 才能拼出 redirect_uri) - local bs, berr = __llmsproxy_login.start_browser({ - platform = plat.id, - start_url = string.format(plat.login_url, 0), -- 端口占位,下面替换 - callback_port = 0, - }) + -- ③ 兜底路径:服务端浏览器。重定向打在服务端回环上,回调命中本插件的挂载点。 + local bs, berr = spawnBrowser(string.format(plat.login_url, 0), "/api/plugins/channels/http/channels/callback") if berr then + out.browser_error = berr + out.hint = "服务端浏览器不可用;先装依赖(见插件一键初始化),或改用其他登录流程" logEvent("服务端浏览器启动失败: " .. berr) - return { - status = 200, - json = { - url = s.url, session = s, platform = plat.id, - browser_error = berr, - hint = "服务端浏览器未启用或不可用;可在设置页开启 VNC 流程(默认关闭)", - }, - } + else + out.view_url = bs.view_url + logEvent("服务端浏览器已就绪 " .. plat.id) + end + else + -- ①②:URL 交给用户自己的浏览器;callback 流程的 redirect_uri 指向本插件挂载点 + if plat.flow == "callback" then + out.url = plat.login_url .. "&redirect_uri=" .. "127.0.0.1:8081/api/plugins/channels/http/channels/callback&state=" .. sess.nonce + else + out.url = plat.login_url end - view_url = bs.view_url - logEvent("服务端浏览器已就绪 " .. plat.id .. " 回调端口=" .. tostring(bs.callback_port)) end - - return { status = 200, json = { - url = s.url, session = s, platform = plat.id, view_url = view_url, - } } + return { status = 200, json = out } end -- plugin.poll_login(session_id) 由页面轮询:会话是否完成。 function plugin.poll_login(req) local id = (req and req.query and req.query.id) or "" - local s = __llmsproxy_login.get(id) + sweepSessions() + local s = sessions()[id] if not s then return { status = 200, json = { state = "gone" } } end return { status = 200, json = { state = s.state, account = s.account, message = s.message } } end @@ -224,6 +340,10 @@ function plugin.complete_login(req) added_at = os.time and os.time() or 0, enabled = true, } + -- 终结该平台未决会话 + for id, sess in pairs(sessions()) do + if sess.platform == plat.id and sess.state == "pending" then sess.state = "completed" end + end logEvent("已登录 " .. plat.name) -- 接入为上游源 —— 这是本插件存在的意义 @@ -361,7 +481,11 @@ function plugin.oauth_callback(req) if not nonce or nonce == "" then return { status = 400, body = "missing state" } end - local s = __llmsproxy_login.get(nonce) + sweepSessions() + local s = nil + for _, sess in pairs(sessions()) do + if sess.nonce == nonce then s = sess break end + end if not s then return { status = 400, body = "unknown or expired login session" } end @@ -376,10 +500,17 @@ function plugin.oauth_callback(req) if res.error then return { status = 400, body = res.error } end - __llmsproxy_login.resolve(s.id, "completed", "oauth", "") + s.state = "completed" + s.account = "oauth" return { status = 200, body = "ok" } end +-- plugin.on_unload:插件被卸载/重载时回收自己拉起的浏览器栈。进程是插件起的, +-- 收尾也是插件的责任 —— 内核不应知道这些进程的存在。 +plugin.on_unload = function() + reapBrowser() +end + -- ============================================================================ -- UI:独立标签页(登录 / 认证 / 配置) -- ============================================================================