Files
ModelRouter/internal/lua/plugin_outbound.go
JianFeeeee 74ec1e7874 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→模型可见)继续通过
2026-10-11 16:24:55 +08:00

153 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}