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