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 通过。
This commit is contained in:
JianFeeeee
2026-10-13 06:57:28 +08:00
parent 84a9fccd9a
commit a07449d32e
5 changed files with 229 additions and 11 deletions

View File

@ -14,6 +14,8 @@ package lua
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -216,3 +218,139 @@ return P
t.Fatalf("body=%q, want the server's JSON", body)
}
}
// TestGrantExtraHostsVisibleToAlreadyBootedWorker 是真实事故的回归测试。
//
// 事故:allowlist 是不可变快照。bootPluginState 在 worker 启动时把 *httpAllowlist
// 指针绑进 Lua 闭包;GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**,已启动的
// worker 仍持旧指针 → 授权接口返回 ok、UI 显示已批,但插件请求仍被
// "not an allowed outbound destination" 拒绝。channels 插件的 auth/state 就是这样
// 在生产被拦的(copilot.tencent.com 已批仍报错)。
//
// 修复:allowlist 改为带锁的**可变共享对象**(原地 add),所有闭包立即可见。
// 本测试按事故顺序构造:先加载插件(worker 已绑定 allowlist)→ 再授权 →
// 断言**同一批已启动的 worker**立刻能用新主机。
func TestGrantExtraHostsVisibleToAlreadyBootedWorker(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
ps.SetOutboundHosts(nil) // 起始只允许 loopback
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
// srv.URL 是 127.0.0.1 —— 非回环测试要另想办法;httptest 只能回环,
// 所以这里用「允许清单里没有的主机名」→ 通过 SetOutboundHosts 授权它,
// 但 srv 本身必须仍回环。做法:插件请求的 URL 用 srv.URL(回环,恒允许)?
// 不行——那测不出 allowlist 更新。改用 checkURL 直接断言新主机可见。
//
// 真实事故的关键路径:worker 已启动 → 授权 → 同一 worker 立即看到。
// Lua 层面用回环无法区分(回环永远允许),因此这里的断言打在两层:
// ① allowlist 对象层:已存在实例 add 后立即 allows() 新主机;
// ② 插件层:SetOutboundHosts 重新折算后,同一 worker 的请求仍工作
// (即 rebuild 不会使旧引用失效)。
host := "portal.example.com"
// ① 对象层:不可变快照时代的 bug 复现点
allow := newHTTPAllowlist(nil)
worker := allow // 模拟 bootPluginState 绑定的指针
if _, err := allow.checkURL("https://" + host + "/x"); err == nil {
t.Fatal("前提不成立:未授权主机不应允许")
}
allow.add([]string{host}) // GrantExtraHosts 的实际路径(原地 add)
if _, err := worker.checkURL("https://" + host + "/x"); err != nil {
t.Fatalf("授权后,已绑定的旧引用仍拒绝新主机(快照 bug 复现): %v", err)
}
// ② 插件层:同一 worker 在 rebuild 后仍可用
ps2 := NewPlugins(NewVM(t.TempDir()), t.TempDir())
ps2.SetOutboundHosts([]string{"127.0.0.1"})
probe := `
local P = {}
P.name = "grantprobe"
P.version = "1.0"
P.exports = { "probe" }
P.hosts = { "portal.example.com" }
function P.probe(arg)
local res, err = ` + httpGlobal + `({ url = arg.url, method = "GET" })
if res == nil then return { error = tostring(err) } end
return { status = res.status }
end
return P
`
if err := ps2.LoadSource("grantprobe", probe); err != nil {
t.Fatal(err)
}
// worker 已启动。现在授权(grant → rebuild → 原地 add)
if err := ps2.GrantExtraHosts("grantprobe", []string{"portal.example.com"}); err != nil {
t.Fatalf("grant: %v", err)
}
// 同一批 worker:一次调用成功即证明 allowlist 引用未被弃用
got, err := ps2.Call("grantprobe", "probe", map[string]interface{}{"url": srv.URL})
if err != nil {
t.Fatal(err)
}
m, _ := got.(map[string]interface{})
if m == nil || m["error"] != nil {
t.Fatalf("授权后同一 worker 调用失败(引用被弃用): %v", got)
}
if s, _ := m["status"].(float64); int(s) != 200 {
t.Fatalf("status=%v, want 200", m["status"])
}
}
// TestHostGrantsPersistAcrossRestart 是第二个真实缺陷的回归:host 授权此前只
// 写进运行态(extraHosts map),从不落盘 —— 重启后授权消失,插件出站全部被拦,
// 而设置页看不出任何异常(actions 有持久化、hosts 没有,读起来像"都持久化了")。
func TestHostGrantsPersistAcrossRestart(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(filepath.Join(dir, "adapters")), dir)
if err := ps.LoadSource("grantprobe", `
local P = {}
P.name = "grantprobe"
P.version = "1.0"
P.exports = {}
P.hosts = { "portal.example.com", "api.partner.example" }
return P
`); err != nil {
t.Fatal(err)
}
if err := ps.GrantExtraHosts("grantprobe", []string{"portal.example.com", "api.partner.example"}); err != nil {
t.Fatal(err)
}
// 落盘验证
b, err := os.ReadFile(filepath.Join(dir, ".kernel-perms.json"))
if err != nil {
t.Fatalf("授权未落盘: %v", err)
}
if !strings.Contains(string(b), "portal.example.com") {
t.Fatalf("host 授权应写入 .kernel-perms.json,文件内容: %s", b)
}
// 重启语义:新 registry 读回授权
ps2 := NewPlugins(NewVM(filepath.Join(dir, "adapters")), dir)
if err := ps2.LoadKernelPermissions(); err != nil {
t.Fatal(err)
}
got := ps2.ExtraHostsGranted()["grantprobe"]
if len(got) != 2 {
t.Fatalf("重启后 host 授权应恢复 2 条, 实得 %v", got)
}
// 插件再加载后,授权可直接使用(GrantExtraHosts 只接受"声明过"的主机,
// 所以重载插件不会丢授权这件事本身就是恢复生效的证据)
if err := ps2.LoadSource("grantprobe", `
local P = {}
P.name = "grantprobe"
P.version = "1.0"
P.exports = {}
P.hosts = { "portal.example.com", "api.partner.example" }
return P
`); err != nil {
t.Fatal(err)
}
got = ps2.ExtraHostsGranted()["grantprobe"]
if len(got) != 2 {
t.Fatalf("插件重载后授权应保留, 实得 %v", got)
}
}