mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
fix(lua): 持久化的出站主机授权在重启后不生效(LoadDir 缺折叠)
生产实测暴露的内核缺陷:**重启后所有已批准出站主机全被拒**。
根因链:
1. `LoadKernelPermissions` 把 `.kernel-perms.json` 里的 `jp.Hosts` 装进
`ps.extraHosts`,但它明确注释「折叠由调用方负责」——**启动路径上没人负责**。
2. `SetOutboundHosts` 早已跑过(core.New 里,在 LoadDir **之前**),
于是 allowlist 只含 source 主机,插件声明的整套 host 全被拒。
3. 症状是经典的「重启丢掉运维的决定」:运维一打开插件页重新保存就恢复。
**它还静默打坏了定时任务**:`refresh_models` 在启动后 5s 补跑
(first_due_now),正好落在这个窗口里,于是每个平台的刷新都报
「host "zcode.z.ai" is not an allowed outbound destination」——看起来像
"白名单缺 host",实际是授权根本没被折叠。我按这个假象查了一圈(去核对
.kernel-perms.json 里 zcode.z.ai 明明在),才定位到折叠这一步缺失。
修法:LoadDir 末尾补 `rebuildOutboundAllowlistLocked()`。
判据:`TestPersistedHostGrantsSurviveRestart` —— 造一份持久化授权、模拟
启动顺序(SetOutboundHosts → LoadDir),断言已批准的 host 可用、
source 主机未丢、未批准的仍被拒。
**变异验证**:撤掉修复 → 判据变红(两条"被拒");加回 → 变绿。
回归:internal/{lua,core,provider,gateway} 全绿。
This commit is contained in:
74
internal/lua/outbound_grant_persist_test.go
Normal file
74
internal/lua/outbound_grant_persist_test.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package lua
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 持久化的出站主机授权必须在**重启后自动生效**。
|
||||||
|
//
|
||||||
|
// 实测缺陷(生产):LoadKernelPermissions 把已批准的 host 装进 ps.extraHosts,
|
||||||
|
// 但注释明说"折叠由调用方负责" —— 启动路径上没人负责。SetOutboundHosts 早已
|
||||||
|
// 跑过(core.New 里、LoadDir 之前),于是 allowlist 只有 source 主机,
|
||||||
|
// 插件声明的整套 host(zcode.z.ai / copilot.tencent.com …)在重启后全被拒:
|
||||||
|
//
|
||||||
|
// host "zcode.z.ai" is not an allowed outbound destination
|
||||||
|
//
|
||||||
|
// 症状是经典的"重启丢掉运维的决定"——运维一打开插件页重新保存就恢复。
|
||||||
|
// 它还打坏了定时任务:refresh_models 在启动后 5s 触发,正好落在那个窗口里,
|
||||||
|
// 于是每个平台的刷新都报"授权缺失"。
|
||||||
|
func TestPersistedHostGrantsSurviveRestart(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
plugDir := filepath.Join(dir, "plugins")
|
||||||
|
if err := os.MkdirAll(plugDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 造一份持久化授权(模拟运维此前在 WebUI 批准过)
|
||||||
|
perms := map[string]interface{}{
|
||||||
|
"channels": map[string]interface{}{
|
||||||
|
"actions": []string{"add_source"},
|
||||||
|
"hosts": []string{"zcode.z.ai", "copilot.tencent.com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(perms)
|
||||||
|
if err := os.WriteFile(filepath.Join(plugDir, ".kernel-perms.json"), b, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟启动:先 SetOutboundHosts(源主机),再 LoadDir(读授权)
|
||||||
|
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||||
|
if err := vm.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(vm.Stop)
|
||||||
|
ps := NewPlugins(vm, plugDir)
|
||||||
|
ps.SetOutboundHosts([]string{"api.example.com"})
|
||||||
|
|
||||||
|
// LoadDir 会读授权文件;没有插件文件也没关系,关键是它必须折叠授权。
|
||||||
|
if err := ps.LoadDir(); err != nil {
|
||||||
|
t.Fatalf("LoadDir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
al := ps.outboundAllowlist()
|
||||||
|
if al == nil {
|
||||||
|
t.Fatal("无 allowlist")
|
||||||
|
}
|
||||||
|
// ① 源主机仍可用
|
||||||
|
if !al.allows("api.example.com", "443") {
|
||||||
|
t.Error("source 主机被丢了")
|
||||||
|
}
|
||||||
|
// ② 已批准的 extra host 必须生效(这就是本判据的核心)
|
||||||
|
for _, h := range []string{"zcode.z.ai", "copilot.tencent.com"} {
|
||||||
|
if !al.allows(h, "443") {
|
||||||
|
t.Errorf("重启后已批准的 host %q 被拒 —— "+
|
||||||
|
"LoadKernelPermissions 装了 extraHosts 但没人折叠进 allowlist", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ③ 没批准过的仍然不许
|
||||||
|
if al.allows("evil.example.com", "443") {
|
||||||
|
t.Error("未批准的 host 不该被放行")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -634,6 +634,24 @@ func (ps *Plugins) LoadDir() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ps.rebuild()
|
ps.rebuild()
|
||||||
|
// Persisted host grants must be folded into the allowlist HERE, not only in
|
||||||
|
// the WebUI grant path.
|
||||||
|
//
|
||||||
|
// Why (live defect): LoadKernelPermissions above loads `jp.Hosts` into
|
||||||
|
// ps.extraHosts but deliberately does NOT fold it — its comment says "the
|
||||||
|
// caller owns that fold". On the boot path nobody did. SetOutboundHosts had
|
||||||
|
// already run (core.New, before LoadDir), so the allowlist held the source
|
||||||
|
// hosts and nothing else, and every approved extra host (the plugin's whole
|
||||||
|
// declared set: zcode.z.ai, copilot.tencent.com, …) was refused after a
|
||||||
|
// restart with "host ... is not an allowed outbound destination".
|
||||||
|
//
|
||||||
|
// Symptom is the classic "restart loses the operator's decision": it clears
|
||||||
|
// the moment an operator opens the plugin page and re-saves the grants. It
|
||||||
|
// also broke the scheduled work — refresh_models fires 5s after boot, inside
|
||||||
|
// that window, and reported the grant as missing for every platform.
|
||||||
|
ps.mu.Lock()
|
||||||
|
ps.rebuildOutboundAllowlistLocked()
|
||||||
|
ps.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user