Files
ModelRouter/internal/lua/plugin_outbound.go
JianFeeeee 028e42b036 fix(plugins): 出站主机授权两处真缺陷 —— 已启动 worker 看不到新授权、授权不落盘
生产实证:channels 插件的 auth/state 在主机已授权后仍被拒
(auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination),
而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。

缺陷一(授权对已启动 worker 无效):
  httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua
  闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**
  (newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回
  ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。
  修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与
  rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker
  无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。

缺陷二(授权不持久化):
  .kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。
  设置页看不出异常(actions 有持久化,读起来像"都持久化了")。
  修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容,
  纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权
  与 actions 一起写入(支持"只批了主机、没批动作"的行)。

回归测试(internal/lua/plugin_http_test.go):
- TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动
  worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。
- TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载
  后授权保留。

go test ./internal/lua/ 全绿;build/vet 通过。
2026-10-13 06:57:28 +08:00

165 lines
5.5 KiB
Go
Raw Permalink 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.
//
// The allowlist object is mutated IN PLACE (allowHTTP.add) rather than replaced:
// already-booted plugin workers hold the ORIGINAL pointer in their Lua closures
// (bootPluginState captured it at boot), so replacing the field would strand
// them on a stale copy — a grant would be recorded and displayed while requests
// kept being refused. Mutating the shared object makes every closure see the
// new hosts immediately.
//
// Note this FOLDS sources + grants additively: a host that loses its grant
// cannot be un-allowed this way, which is acceptable because grants are
// append-only operator approvals (revocation is a restart away — the persisted
// file is the authority on boot).
//
// 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.add(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
}