package lua import ( "fmt" "sort" "strings" golua "github.com/aarzilli/golua/lua" ) // Package lua — Lua 绑定:凭据保险库 + 内核动作面 // // 这两个全局把"插件自己主导链接认证"所需的两块补齐: // // __llmsproxy_secrets —— 安全存放 OAuth token / session / 设备指纹 // __llmsproxy_kernel —— 触发内核动作(注册源、探测、清冷却、改链路…) // // 注册点与 __llmsproxy_http 相同:只在 PLUGIN 的 bootPluginState 里装, // 因为适配器共用 setupGlobals 而不该拿到这些能力。 // // 两张表都用 buildJSONTable 的既有写法(NewTable + PushGoClosure + SetField), // 该写法在 json 表上长期稳定,也是内核自己的用法。 // secretGlobal 是凭据保险库的 Lua 名字。 const secretGlobal = "__llmsproxy_secrets" // kernelGlobal 是内核动作面的 Lua 名字。 const kernelGlobal = "__llmsproxy_kernel" // registerSecretBridge installs the credential vault onto a plugin's state. // // The plugin name is bound HERE, at boot, rather than read from the Lua state at // call time. That is what makes "a plugin can only read its OWN credentials" a // mechanism instead of a convention: a shard or any other holder of the compiled // script cannot pass a different name. func registerSecretBridge(L *golua.State, ps *Plugins, name string) { L.NewTable() L.PushGoClosure(luaSecretSet(ps, name)) L.SetField(-2, "set") L.PushGoClosure(luaSecretGet(ps, name)) L.SetField(-2, "get") L.PushGoClosure(luaSecretDelete(ps, name)) L.SetField(-2, "delete") L.PushGoClosure(luaSecretKeys(ps, name)) L.SetField(-2, "keys") L.SetGlobal(secretGlobal) } // luaSecretSet: set(key, value) -> (true, nil) | (nil, err) func luaSecretSet(ps *Plugins, name string) golua.LuaGoFunction { return func(L *golua.State) int { key := L.ToString(1) val := L.ToString(2) if err := ps.SetSecret(name, key, val); err != nil { L.PushNil() L.PushString(err.Error()) return 2 } L.PushBoolean(true) L.PushNil() return 2 } } // luaSecretGet: get(key) -> (value, nil) | (nil, err). A missing key yields // (nil, nil) so a plugin can test `if tok == nil then login() end` — the "no // credential stored yet" case is the common one for a fresh install, and making // it an error would force every caller to string-match on a message. func luaSecretGet(ps *Plugins, name string) golua.LuaGoFunction { return func(L *golua.State) int { key := L.ToString(1) v, ok, err := ps.GetSecret(name, key) if err != nil { L.PushNil() L.PushString(err.Error()) return 2 } if !ok { L.PushNil() L.PushNil() return 2 } L.PushString(v) L.PushNil() return 2 } } // luaSecretDelete: delete(key) -> (true, nil) | (nil, err) func luaSecretDelete(ps *Plugins, name string) golua.LuaGoFunction { return func(L *golua.State) int { key := L.ToString(1) if err := ps.DeleteSecret(name, key); err != nil { L.PushNil() L.PushString(err.Error()) return 2 } L.PushBoolean(true) L.PushNil() return 2 } } // luaSecretKeys: keys() -> (array of key names, nil). Names only — a plugin can // render "which accounts are configured" without ever echoing token bytes. func luaSecretKeys(ps *Plugins, name string) golua.LuaGoFunction { return func(L *golua.State) int { keys, err := ps.SecretKeys(name) if err != nil { L.PushNil() L.PushString(err.Error()) return 2 } L.NewTable() for i, k := range keys { L.PushString(k) L.RawSeti(-2, i+1) } L.PushNil() return 2 } } // registerKernelBridge installs the kernel action surface onto a plugin's state. func registerKernelBridge(L *golua.State, ps *Plugins, name string) { L.NewTable() L.PushGoClosure(luaKernelActions(ps, name)) L.SetField(-2, "actions") L.PushGoClosure(luaKernelCall(ps, name)) L.SetField(-2, "call") L.SetGlobal(kernelGlobal) } // luaKernelActions: actions() -> (array of {name,title,danger,description,params, // granted}, nil) // // It reports GRANTED alongside each action so a plugin can degrade gracefully // (skip the part that needs a permission it does not have) instead of // discovering it by hitting an error mid-operation. func luaKernelActions(ps *Plugins, name string) golua.LuaGoFunction { return func(L *golua.State) int { perm := ps.PluginKernelPermission(name) L.NewTable() for i, a := range KernelActions() { L.NewTable() L.PushString(a.Name) L.SetField(-2, "name") L.PushString(a.Title) L.SetField(-2, "title") L.PushString(a.Danger) L.SetField(-2, "danger") L.PushString(a.Description) L.SetField(-2, "description") L.PushBoolean(perm.allows(a)) L.SetField(-2, "granted") L.NewTable() for j, p := range a.Params { L.PushString(p) L.RawSeti(-2, j+1) } L.SetField(-2, "params") L.RawSeti(-2, i+1) } L.PushNil() return 2 } } // luaKernelCall: call(name, args) -> (result, nil) | (nil, err) // // One entry point rather than a function per action: the action set will change, // and a table field per action would mean editing bootPluginState — the exact // place that produced a cgo crash during earlier work. func luaKernelCall(ps *Plugins, name string) golua.LuaGoFunction { return func(L *golua.State) int { action := L.ToString(1) args := map[string]interface{}{} if L.Type(2) == golua.LUA_TTABLE { var raw interface{} if err := luaToJSON(L, 2, &raw); err == nil { if m, ok := raw.(map[string]interface{}); ok { args = m } } } v, err := ps.callKernel(name, action, args) if err != nil { L.PushNil() L.PushString(err.Error()) return 2 } if v == nil { L.PushNil() } else { pushGoValue(L, v) } L.PushNil() return 2 } } // readPluginKernelDeclared reads `plugin.kernel`, the actions a plugin asks for. // // The shape is a list of names: // // plugin.kernel = { "status", "reset_health" } // // Unknown names are kept rather than dropped so the settings page can show a // typo as "requested but not a real action" — silently discarding it would make // a plugin's intent invisible at exactly the moment the operator is deciding // what to grant. func readPluginKernelDeclared(L *golua.State) []string { raw, ok := pluginFieldValue(L, "kernel") if !ok { return nil } items, isList := raw.([]interface{}) if !isList { if one, isTable := raw.(map[string]interface{}); isTable { // A single string field or a set-style table; accept both spellings. if v, ok := one["actions"].([]interface{}); ok { items = v } else { return nil } } else { return nil } } out := make([]string, 0, len(items)) seen := map[string]bool{} for _, it := range items { name, ok := it.(string) if !ok { continue } name = strings.TrimSpace(name) if name == "" || seen[name] { continue } seen[name] = true out = append(out, name) } sort.Strings(out) return out } // loginGlobal is the Lua name of the login-session bridge. const loginGlobal = "__llmsproxy_login" // registerLoginBridge installs the login session API. // // A plugin drives the whole flow itself (it knows the platform's endpoints) and // the kernel only owns the session lifecycle: create, poll, resolve, cancel. // That split is what lets one kernel serve OAuth, device-code, QR and paste // flows without containing any platform knowledge. func registerLoginBridge(L *golua.State, ps *Plugins, name string) { L.NewTable() L.PushGoClosure(luaLoginBegin(ps, name)) L.SetField(-2, "begin") L.PushGoClosure(luaLoginGet(ps, name)) L.SetField(-2, "get") L.PushGoClosure(luaLoginResolve(ps, name)) L.SetField(-2, "resolve") L.PushGoClosure(luaLoginList(ps, name)) L.SetField(-2, "list") L.PushGoClosure(luaLoginCancel(ps, name)) L.SetField(-2, "cancel") // 服务端浏览器(回环类登录的唯一自动化路径)。与登录会话同一张表, // 因为它就是登录的一种流程,而不是独立能力。 L.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). func luaTableArg(L *golua.State, idx int) (map[string]interface{}, error) { if L.Type(idx) != golua.LUA_TTABLE { return nil, fmt.Errorf("expected a table argument, got %s", normalizeString(L, idx)) } var raw interface{} if err := luaToJSON(L, idx, &raw); err != nil { return nil, err } m, ok := raw.(map[string]interface{}) if !ok { return nil, fmt.Errorf("argument must be a keyed table") } return m, nil } // readPluginAuthHosts reads `plugin.auth_hosts`, the login hosts a plugin needs. // // plugin.auth_hosts = { "auth.example.com", "accounts.google.com" } // // A list of bare hostnames — not URLs — because the allowlist matches on host, // and accepting full URLs would invite a plugin to smuggle a path or scheme that // the operator did not read. Entries are normalised so the granted and declared // forms always compare equal. func readPluginAuthHosts(L *golua.State) []string { raw, ok := pluginFieldValue(L, "auth_hosts") if !ok { return nil } items, isList := raw.([]interface{}) if !isList { if one, isTable := raw.(map[string]interface{}); isTable { if v, ok := one["hosts"].([]interface{}); ok { items = v } else { return nil } } else { return nil } } out := make([]string, 0, len(items)) seen := map[string]bool{} for _, it := range items { s, ok := it.(string) if !ok { continue } h := normalizeHost(s) if h == "" || seen[h] { continue } seen[h] = true out = append(out, h) } sort.Strings(out) return out }