refactor(lua): 内核回归六项职责 —— 登录/浏览器全部移入插件

用户钉死了内核的职责边界,本提交把越界部分全部拆除:

  内核只做:加载适配器 · auto 调度 · 加载插件 · 调度插件 · 维护源与 key · 签发密钥

## 架构纠正(用户指出的根本错误)

我此前把信任模型弄反了。实测:插件 VM 的 os.execute/io.popen/package 全部可达
(true,true,true) —— 插件从来不是 OS 级沙箱。正确的边界是:

  插件   可信(运维安装),非沙箱,有完整 OS 能力 → 平台知识/登录/浏览器归它
  适配器 不可信(可被动态安装),请求级 → 沙箱归它(12 个内置适配器实测均不碰
         os/io/package,后续沙箱化零风险,属独立一步)

## 删除(内核越界部分)

- plugin_browser.go        内核浏览器管理器(Xvfb/chromium/x11vnc/websockify
                           进程栈、回环监听器)—— 388 行全删,浏览器由插件用
                           os.execute 自己拉起(实测 spawn+reap 闭环可用)
- plugin_login.go          登录会话状态机 + LoginFlow 枚举 + VNC 全局门禁
                           —— 457 行全删。"登录"从内核概念中消失
- gateway/browser_proxy.go viewer 反代
- handlePluginLogin API    登录会话列表端点
- vnc_enabled 全局开关     改为 per-plugin 授权(见下)

## 新增(内核职责内的通用机制,无任何登录/浏览器语义)

- plugin_outbound.go       通用额外出站主机授权:插件声明 plugin.hosts(请求)
                           → 运维在设置页逐个批准(授权)。与 kernel actions
                           同一"声明≠授权"模型。内核只知道白名单加减,不知道
                           host 被用来干什么
- on_unload 生命周期钩子   卸载是"调度插件"的一部分:内核在关闭插件状态前调用
                           插件导出的 on_unload,让它自清理外部资源。内核不知道
                           资源是什么(进程/句柄/远端会话都行)。带进程回收回归
                           测试 TestOnUnloadReapsPluginProcesses

## channels.lua 全量接管

- 登录会话自持:plugin.state.sessions(nonce 只在 begin 响应出现一次,
  惰性过期清理)
- 服务端浏览器栈自持:spawnBrowser 用 io.popen 拉起 Xvfb/chromium/x11vnc/
  websockify 并记 pid,reapBrowser 回收;on_unload 兜底
- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化(轮询/nonce 回调)
  → ③ VNC 无头浏览器兜底(无浏览器系统/自动化无法完成的场景,如回环重定向
  必须落在服务端)
- plugin.auth_hosts → plugin.hosts(语义泛化:出站主机授权与登录无关)

## 验收

- 全仓旧符号零残留(grep 权威确认,代码+注释+lua+测试)
- 内核非测试代码零厂商名
- build + vet 全绿;lua/core/gateway/types/scheduler/provider 全量测试通过
- channels 端到端(登录→凭据→add_source→模型可见)继续通过
This commit is contained in:
JianFeeeee
2026-10-11 16:24:55 +08:00
parent fa50ec8bc7
commit 74ec1e7874
14 changed files with 425 additions and 1778 deletions

View File

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

View File

@ -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, "<h2>Login received. You can close this tab.</h2>")
})
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)
}

View File

@ -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("非回环平台不该起服务端浏览器")
}
}

View File

@ -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/<name>/...) and the
// platform's redirect lands there with the code. The session carries the
// state nonce so a callback cannot be replayed into the wrong session.
FlowCallback LoginFlow = "callback"
// FlowPaste: the operator pastes a cookie or token into the settings page.
// Covers the cookie-based channels (133 hits in the reference plugin) where
// no OAuth exists at all.
FlowPaste LoginFlow = "paste"
// FlowVNC: a server-side browser exposed over VNC. Opt-in escape hatch, off
// by default (see the header note).
FlowVNC LoginFlow = "vnc"
)
// LoginSessionState is the lifecycle of one attempt.
type LoginSessionState string
const (
LoginPending LoginSessionState = "pending"
LoginCompleted LoginSessionState = "completed"
LoginFailed LoginSessionState = "failed"
LoginExpired LoginSessionState = "expired"
LoginCancelled LoginSessionState = "cancelled"
)
// LoginSession is one in-flight authentication attempt.
//
// It is created by a plugin (or the settings page) and polled until it reaches a
// terminal state. The credential itself is never stored here — the plugin stores
// it in its own vault (__llmsproxy_secrets) and reports bare completion. Keeping
// tokens out of the session record means a session listing, which IS exposed to
// the UI, can never leak one.
type LoginSession struct {
ID string `json:"id"`
Plugin string `json:"plugin"`
Platform string `json:"platform"`
Flow LoginFlow `json:"flow"`
State LoginSessionState `json:"state"`
// URL is what the operator must open (link-poll) or the VNC entry point.
URL string `json:"url,omitempty"`
// UserCode is the short code a device flow shows (e.g. "WDJB-MJHT").
UserCode string `json:"user_code,omitempty"`
// State nonce for the callback flow. A callback whose state does not match is
// refused, so an attacker cannot inject a code into someone else's session.
Nonce string `json:"-"`
// Message explains a non-pending state in the operator's terms.
Message string `json:"message,omitempty"`
// Account is a display label the plugin reports on success (a login name),
// never a secret.
Account string `json:"account,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// loginSessionTTL bounds how long a pending attempt lives. A login the operator
// abandoned must not leave a pollable session (and a browser tab prompt) forever;
// the reference implementation uses the same 15 minute idea.
const loginSessionTTL = 15 * time.Minute
// loginSessions owns all in-flight attempts.
type loginSessions struct {
mu sync.Mutex
byID map[string]*LoginSession
}
func newLoginSessions() *loginSessions {
return &loginSessions{byID: map[string]*LoginSession{}}
}
// newLoginID returns an unguessable session id.
//
// Unguessable matters: the id is the handle the settings page polls, and the
// callback route carries its nonce. A sequential id would let a caller enumerate
// other plugins' pending logins.
func newLoginID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand failing is not a condition this process can serve around;
// fall back to a time-based id rather than returning empty.
return fmt.Sprintf("ls-%d", time.Now().UnixNano())
}
return "ls-" + hex.EncodeToString(b[:])
}
// newLoginNonce returns the callback state nonce.
func newLoginNonce() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("n-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
// BeginLogin starts a session. The plugin decides the flow and supplies what
// that flow needs; the kernel only owns the lifecycle.
func (ps *Plugins) BeginLogin(s LoginSession) (*LoginSession, error) {
if s.Plugin == "" {
return nil, fmt.Errorf("login session requires a plugin")
}
if ps.find(s.Plugin) == nil {
return nil, fmt.Errorf("plugin %s not found", s.Plugin)
}
switch s.Flow {
case FlowLinkPoll, FlowCallback, FlowPaste, FlowVNC:
default:
return nil, fmt.Errorf("unknown login flow %q", s.Flow)
}
if s.Flow == FlowVNC && !ps.VNCEnabled() {
return nil, fmt.Errorf("the VNC login flow is disabled; enable it in settings if the platform " +
"requires a server-side browser (it is off by default for security)")
}
now := time.Now()
out := &LoginSession{
ID: newLoginID(),
Plugin: s.Plugin,
Platform: s.Platform,
Flow: s.Flow,
State: LoginPending,
URL: s.URL,
UserCode: s.UserCode,
Nonce: newLoginNonce(),
CreatedAt: now,
ExpiresAt: now.Add(loginSessionTTL),
}
ps.logins().mu.Lock()
ps.logins().sweepLocked(now)
ps.logins().byID[out.ID] = out
ps.logins().mu.Unlock()
cp := out.clone()
return &cp, nil
}
// GetLogin returns one session, expiring it if its TTL has passed.
//
// Expiry is applied on READ rather than by a background timer: a gateway with no
// logins in flight pays nothing, and a session can never be observed as pending
// after its deadline even if no goroutine happens to have run.
func (ps *Plugins) GetLogin(id string) (*LoginSession, bool) {
now := time.Now()
ls := ps.logins()
ls.mu.Lock()
defer ls.mu.Unlock()
ls.sweepLocked(now)
s, ok := ls.byID[id]
if !ok {
return nil, false
}
cp := s.clone()
return &cp, true
}
// ResolveLogin moves a session to a terminal state. It is the only way a session
// ends successfully, so the credential handling stays in one place.
//
// `account` is a display label only — never a token. The plugin has already
// stored the credential in its vault by the time it calls this; passing it
// through here would put it in a UI-visible record.
func (ps *Plugins) ResolveLogin(id string, state LoginSessionState, account, message string) error {
switch state {
case LoginCompleted, LoginFailed, LoginExpired, LoginCancelled:
default:
return fmt.Errorf("resolveLogin needs a terminal state, got %q", state)
}
ls := ps.logins()
ls.mu.Lock()
defer ls.mu.Unlock()
s, ok := ls.byID[id]
if !ok {
return fmt.Errorf("no such login session")
}
if s.isTerminal() {
// Already finished. Reporting success again would let a plugin turn a
// cancelled session into a completed one.
return fmt.Errorf("login session is already %s", s.State)
}
s.State = state
s.Account = account
s.Message = message
return nil
}
// MatchCallbackNonce finds the pending callback session for a nonce.
//
// The nonce is the CSRF defence for the callback flow: a redirect that does not
// carry the nonce of a live session is not answered, so a code delivered to an
// attacker-controlled session cannot be replayed into this gateway's login.
func (ps *Plugins) MatchCallbackNonce(nonce string) (*LoginSession, bool) {
if nonce == "" {
return nil, false
}
now := time.Now()
ls := ps.logins()
ls.mu.Lock()
defer ls.mu.Unlock()
ls.sweepLocked(now)
for _, s := range ls.byID {
if s.Nonce == nonce && s.Flow == FlowCallback && !s.isTerminal() {
cp := s.clone()
return &cp, true
}
}
return nil, false
}
// ListLogins returns a plugin's sessions, newest first, for its settings page.
func (ps *Plugins) ListLogins(plugin string) []LoginSession {
now := time.Now()
ls := ps.logins()
ls.mu.Lock()
defer ls.mu.Unlock()
ls.sweepLocked(now)
out := make([]LoginSession, 0, 4)
for _, s := range ls.byID {
if s.Plugin != plugin {
continue
}
cp := s.clone()
cp.Nonce = "" // never expose the nonce to the UI
out = append(out, cp)
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out
}
// CancelLogins drops a plugin's pending sessions. Used on plugin unload so an
// uninstalled plugin leaves no pollable login behind.
func (ps *Plugins) CancelLogins(plugin string) int {
ls := ps.logins()
ls.mu.Lock()
defer ls.mu.Unlock()
n := 0
for _, s := range ls.byID {
if s.Plugin == plugin && !s.isTerminal() {
s.State = LoginCancelled
n++
}
}
return n
}
// sweepLocked expires overdue sessions and reaps terminal ones. Caller holds mu.
func (ls *loginSessions) sweepLocked(now time.Time) {
for id, s := range ls.byID {
if !s.isTerminal() && now.After(s.ExpiresAt) {
s.State = LoginExpired
s.Message = "the login attempt timed out; start it again"
}
// Terminal sessions are reaped after a grace period so the UI can show
// the outcome. Deleting immediately would make a completed login look
// like it never happened.
if s.isTerminal() && now.After(s.ExpiresAt.Add(loginSessionTTL)) {
delete(ls.byID, id)
}
}
}
func (s *LoginSession) isTerminal() bool {
switch s.State {
case LoginCompleted, LoginFailed, LoginExpired, LoginCancelled:
return true
}
return false
}
// clone returns a copy so callers cannot mutate the stored session, and so a
// data race cannot expose a half-updated struct to the UI.
func (s *LoginSession) clone() LoginSession {
cp := *s
return cp
}
// loginSessions lazily creates the store so a Plugins value built in a test
// without construction still works.
func (ps *Plugins) logins() *loginSessions {
ps.loginOnce.Do(func() {
if ps.loginStore == nil {
ps.loginStore = newLoginSessions()
}
})
return ps.loginStore
}
// VNCEnabled reports whether the VNC escape hatch is on. Off by default: a
// server-side browser is the highest-risk login path and must be a deliberate
// operator choice.
func (ps *Plugins) VNCEnabled() bool {
ps.mu.RLock()
defer ps.mu.RUnlock()
return ps.vncPermitted
}
// SetVNCEnabled turns the VNC login flow on or off.
func (ps *Plugins) SetVNCEnabled(on bool) {
ps.mu.Lock()
ps.vncPermitted = on
ps.mu.Unlock()
}
// ---- 登录主机授权 ----
//
// 登录流程要访问的 host(auth.huaweicloud.com、accounts.google.com…)不是源,
// 所以出站白名单默认会掐断整个流程(已实测确认)。这是**正确**的默认:白名单
// 的意义就是"插件只能去它该去的地方"。
//
// 解法与内核权限同一个姿态:插件在源码里**声明**它需要哪些登录 host
// (plugin.auth_hosts),运维在设置页批准后才放行。声明是请求,批准才是授权。
//
// 为什么要声明而不是给个通用开关:一个能访问任意 host 的插件等于没有白名单,
// 而"登录"这个理由足够正当,正当到很容易被用来把整张名单打开。要求逐个列出,
// 运维才有机会看出"这个插件为什么要访问一个我没听说过的域名"。
// PluginAuthHosts returns the login hosts a plugin declared.
func (ps *Plugins) PluginAuthHosts(plugin string) []string {
ps.mu.RLock()
defer ps.mu.RUnlock()
for _, p := range ps.plugins {
if p.Info.Name == plugin {
return append([]string(nil), p.AuthHosts...)
}
}
return nil
}
// GrantAuthHosts approves a set of login hosts for a plugin.
//
// Only hosts the plugin DECLARED may be granted: accepting an arbitrary host from
// the settings page would make the declaration decorative, and the point of the
// declaration is that the operator reviews a list the plugin itself committed to
// in source that is on disk and reviewable.
func (ps *Plugins) GrantAuthHosts(plugin string, hosts []string) error {
if ps.find(plugin) == nil {
return fmt.Errorf("plugin %s not found", plugin)
}
declared := map[string]bool{}
for _, h := range ps.PluginAuthHosts(plugin) {
declared[normalizeHost(h)] = true
}
clean := make([]string, 0, len(hosts))
for _, h := range hosts {
hn := normalizeHost(h)
if hn == "" {
continue
}
if !declared[hn] {
return fmt.Errorf("plugin %s did not declare auth host %q", plugin, hn)
}
clean = append(clean, hn)
}
sort.Strings(clean)
ps.mu.Lock()
if ps.authHosts == nil {
ps.authHosts = map[string][]string{}
}
if len(clean) == 0 {
delete(ps.authHosts, plugin)
} else {
ps.authHosts[plugin] = clean
}
ps.rebuildOutboundAllowlistLocked()
ps.saveKernelPermissionsLocked()
ps.mu.Unlock()
return nil
}
// AuthHostsGranted returns the approved hosts for each plugin (for the UI).
func (ps *Plugins) AuthHostsGranted() map[string][]string {
ps.mu.RLock()
defer ps.mu.RUnlock()
out := make(map[string][]string, len(ps.authHosts))
for k, v := range ps.authHosts {
out[k] = append([]string(nil), v...)
}
return out
}
// normalizeHost lowercases a host and strips a port, so "Auth.Example.com:443"
// and "auth.example.com" cannot both be granted under different names.
func normalizeHost(h string) string {
h = strings.ToLower(strings.TrimSpace(h))
if i := strings.LastIndex(h, ":"); i > 0 && !strings.Contains(h[i:], "]") {
// Leave IPv6 literals ("[::1]:443") alone apart from the port.
h = h[:i]
}
h = strings.Trim(h, "[]")
return h
}
// rebuildOutboundAllowlistLocked folds the SOURCE hosts and every GRANTED auth
// host into the allowlist the HTTP bridge consults. Caller holds ps.mu.
//
// Declared-but-not-granted hosts are deliberately excluded: that exclusion is the
// whole security property, so it is the one thing this function must not be
// tempted to "helpfully" relax.
func (ps *Plugins) rebuildOutboundAllowlistLocked() {
hosts := append([]string(nil), ps.sourceHosts...)
for _, granted := range ps.authHosts {
hosts = append(hosts, granted...)
}
ps.allowHTTP = newHTTPAllowlist(hosts)
}

View File

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

View File

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

View File

@ -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 未被调用")
}
}

View File

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

View File

@ -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:独立标签页(登录 / 认证 / 配置)
-- ============================================================================