From 07d35297a67e9d4bed315f57734dc6260702c3ab Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 4 Nov 2026 17:15:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(webui):=20=E5=8F=8D=E4=BB=A3=E4=B8=89?= =?UTF-8?q?=E5=A4=84=E7=BC=BA=E9=99=B7=20=E2=80=94=E2=80=94=20=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E4=B8=8D=E6=98=BE=E7=A4=BA=E3=80=81=E5=AD=90=E5=9F=9F?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=A4=B1=E6=95=88=E3=80=81=E5=89=8D=E7=BC=80?= =?UTF-8?q?=E9=87=8D=E5=AE=9A=E5=90=91=E9=80=83=E9=80=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全部来自本机 systemd 实例的实测,不是推测。 1. 插件页「服务入口」永远显示"暂无" TABS.plugins.fetch 漏了 proxyServices,而 renderPlugins 第一行就调 renderProxyServicesCard()(读 state.proxyServices)。于是该页签 永远不请求 /api/v1/proxy/services —— journalctl 实测:用户正常使用 期间零请求,只有我手工 curl 的那一条。 判据 TestProxyServicesReachesPluginsTab 钉住三环(A 调用 B、B 读 state、C fetch 含块名),已做变异验证(删掉该项立刻红)。 不用静态调用图做通用依赖检查:实测它在 dashboard.js 上不可靠 (中文注释引号破坏配平、分发器展开导致大量误报),已在注释里 写明原因,避免后人重复踩。 2. 面板生成的子域链接 100% 进不了反代 生成链接走 base_url 的主机名,路由匹配只认 base_domain。 生产实例 base_url=homeagent.jianfgit.xyz、base_domain=localhost, 于是链接落到门户 mux → 302 /login,而手拼 *.localhost 反而能进。 新增 proxyBaseDomains 返回全部候选基域(配置值 → base_url 主机名 → localhost),serveProxyHost 用它匹配。两者都认,不是二选一: *.localhost 是 RFC 6761 的零配置基座,不能丢。 3. 前缀模式下上游根绝对路径重定向逃出挂载点 实测 NapCat:上游 GET / → 301 Location: /webui,浏览器跟随到 https://<门户>/webui(已跑出 /napcat),门户 mux 接住 → 302 /login。 httputil.ReverseProxy 不改写 Location,在 ModifyResponse 里补: 只改以 / 开头且未带前缀的,避开 //host 与已经带前缀的(否则会出现 /napcat/napcat/webui)。 顺带纠正:base_domain 保持原配置不动,改由代码兼容两种基域 —— 改配置会抢走别的 *.jianfgit.xyz 子域,且 *.jianfgit.xyz 是单层通配, huawei-smarthome.homeagent.jianfgit.xyz 本来就证书不匹配(实测 verify error 62)。要外网可用子域形态,应把 base_domain 配成 jianfgit.xyz,那是运维决策,不在本次改动内。 --- internal/plugins/webui/dashboard.js | 10 +- .../plugins/webui/dashboard_tabs_deps_test.go | 302 ++++++++++++++++++ internal/plugins/webui/proxy.go | 99 +++++- internal/sdk/plugin.go | 8 + third_party/hologramagentsdk/sdk/plugin.go | 5 + third_party/hologramagentsdk/sdk/proxy.go | 72 ++++- 6 files changed, 489 insertions(+), 7 deletions(-) create mode 100644 internal/plugins/webui/dashboard_tabs_deps_test.go diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index d1a91fb1..c2bf849f 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -545,7 +545,15 @@ var TABS = { }, starmap: { fetch: [], render: ["renderStarmapTab"] }, plugins: { - fetch: ["status", "kernel", "plugins", "runtime"], + // ★ proxyServices 必须在这里:renderPlugins 的第一行就是 + // renderProxyServicesCard()(读 state.proxyServices)。 + // 漏了它 ⇒「服务入口」卡片永远显示「暂无被反代的插件服务」, + // 而 /api/v1/proxy/services 根本没被请求过(生产实测)。 + // 历史:86690da 的懒加载重构把依赖分析写成了注释,注释里 + // 列的 renderPlugins 依赖是 kernel/installedPlugins/disabledPlugins/ + // pluginInfo —— 漏了 proxyServices,因为它是**间接**经 + // renderProxyServicesCard 读的。见 TestTabsFetchCoversRenderDeps。 + fetch: ["status", "kernel", "plugins", "runtime", "proxyServices"], render: ["renderPlugins"], }, settings: { fetch: ["settings", "status"], render: ["renderOneSettings"] }, diff --git a/internal/plugins/webui/dashboard_tabs_deps_test.go b/internal/plugins/webui/dashboard_tabs_deps_test.go new file mode 100644 index 00000000..71f2651b --- /dev/null +++ b/internal/plugins/webui/dashboard_tabs_deps_test.go @@ -0,0 +1,302 @@ +package webui + +// 判据:「插件」页签的「服务入口」必须真的被请求到数据。 +// +// ## 要判的是什么(2026-11-04 生产实测) +// +// 「插件」页签的「服务入口」卡片永远显示「暂无被反代的插件服务」, +// 而 /api/v1/proxy/services **一次都没被请求过**(journalctl 实测: +// 该端点只有我手工 curl 的那一条 401,用户正常使用期间零请求)。 +// +// 根因:`renderPlugins()` 第一行就调 `renderProxyServicesCard()`, +// 而后者读 `state.proxyServices`;但 TABS 表里 +// +// plugins: { fetch: ["status","kernel","plugins","runtime"], ... } ← 没有 proxyServices +// +// 于是 `state.proxyServices` 永远是 undefined ⇒ `svcs = []` ⇒ 空卡片。 +// 只有先点进「对话」页(那里 fetch 了 proxyServices)再回「插件」才能看到。 +// +// ## 为什么钉「三条事实」而不是做静态调用图 +// +// 我第一版写了个递归调用图提取器(render 根 → 展开同文件调用 → 收集 +// `state.X` 读点 → 比对 TABS.fetch)。它在实测中**完全失效**: +// +// 1. 中文注释里的引号(「」与 ')会破坏括号/字符串配平扫描, +// `escHtml` 的函数体被误判成覆盖到文件末尾 ⇒ renderAdapters 的 +// "调用链"里出现 renderKernel / renderOneSettings / renderAll…… +// 2. 分发器 `starmapFetchBlock` 的 switch 覆盖全部块名,展开它会让 +// 任何调过它的 render 都被判成「缺十几块数据」。 +// 3. 按需加载的 state(messages / chatOffset / starmapInit)不属于 +// TABS.fetch,误报成缺口。 +// +// 结论:**JS 的静态依赖分析在这个文件上不可靠**。可靠的是钉住本缺陷 +// 的**结构性链条**——三件必须同时成立的事,每件都是一次廉价字符串检查: +// +// A. renderPlugins 确实调用了 renderProxyServicesCard +// B. renderProxyServicesCard 确实读 state.proxyServices +// C. TABS.plugins.fetch 确实含 "proxyServices" +// +// 任何一环断了都不会再有"空卡片"这种静默故障:A 断 ⇒ 卡片不再渲染 +// (显而易见的 UI 变化,评审会看见);B 断 ⇒ 读不到数据(同样明显)。 +// C 是本次故障的那一环,也是唯一纯粹"看不见"的一环。 +// +// 更通用的依赖覆盖检查需要真正的 JS 语义分析(或在 harness 里记录 +// fetch 调用序列并与页签切换做配对断言)。那属于测试补强,另行安排; +// 这里刻意只保留一条不会误报、不会假绿的判据。 + +import ( + "strings" + "testing" +) + +// funcBody 取顶层 `function name(` 的花括号体(配平扫描)。 +// +// 只在**函数体内部**做配平,遇到不平衡就返回空串让调用方判失败—— +// 宁可判「找不到」也不要基于错误范围给出假结论。 +func funcBody(js, name string) string { + marker := "function " + name + "(" + idx := strings.Index(js, marker) + if idx == -1 { + return "" + } + // 从开括号起配平参数表。marker 已包含那个 '(',所以深度从 1 起。 + i := idx + len(marker) + depth := 1 + for ; i < len(js); i++ { + switch js[i] { + case '(': + depth++ + case ')': + depth-- + } + if depth == 0 { + break + } + } + if i >= len(js) { + return "" + } + // 跳过返回类型/空白,定位函数体 '{'。 + for ; i < len(js) && js[i] != '{'; i++ { + if js[i] == ';' || js[i] == '=' { + return "" // 不是声明,是别的形态 + } + } + if i >= len(js) { + return "" + } + start := i + d := 0 + inStr := byte(0) + for ; i < len(js); i++ { + c := js[i] + if inStr != 0 { + if c == '\\' { + i++ + continue + } + if c == inStr { + inStr = 0 + } + continue + } + switch c { + case '"', '\'', '`': + inStr = c + case '{': + d++ + case '}': + d-- + if d == 0 { + return js[start : i+1] + } + } + } + return "" +} + +// TestProxyServicesReachesPluginsTab 是本次实测缺陷的回归判据。 +// +// 变异验证:把 TABS.plugins.fetch 里的 "proxyServices" 删掉 ⇒ 立刻红。 +func TestProxyServicesReachesPluginsTab(t *testing.T) { + js := dashboardJSSource(t) + + // —— A. renderPlugins 调用 renderProxyServicesCard —— + plug := funcBody(js, "renderPlugins") + if plug == "" { + t.Fatal("找不到 renderPlugins 的函数体 —— 解析器与源码形态不同步,本判据此刻是假绿") + } + if !strings.Contains(plug, "renderProxyServicesCard(") { + t.Fatalf("renderPlugins 不再调用 renderProxyServicesCard()。\n" + + "若这是刻意移除,请同时删掉本判据;若是重构漏了,服务入口卡片就消失了。") + } + + // —— B. renderProxyServicesCard 读取 state.proxyServices —— + card := funcBody(js, "renderProxyServicesCard") + if card == "" { + t.Fatal("找不到 renderProxyServicesCard 的函数体 —— 同上,假绿风险") + } + if !strings.Contains(card, "state.proxyServices") { + t.Fatalf("renderProxyServicesCard 不再读 state.proxyServices —— " + + "要么它换了数据源(本判据需同步),要么卡片会永远为空") + } + + // —— C. TABS.plugins.fetch 含 proxyServices —— + tabs := parseTabs(t, js) + for _, tab := range tabs { + if tab.name != "plugins" { + continue + } + for _, f := range tab.fetch { + if f == "proxyServices" { + return // 三环皆成立 + } + } + t.Fatalf("TABS.plugins.fetch 缺少 \"proxyServices\"。\n"+ + "症状(2026-11-04 生产实测):「服务入口」卡片永远显示"+ + "「暂无被反代的插件服务」,即使插件确实声明了 proxies ——"+ + "因为 /api/v1/proxy/services 根本没被请求。\n"+ + "renderPlugins 第一行调用 renderProxyServicesCard(),后者读 state.proxyServices;"+ + "renderPlugins 是 TABS.plugins.render,所以 proxyServices 必须在该页签的 fetch 里。\n"+ + "当前 fetch = %v", tab.fetch) + } + t.Fatal("TABS 里找不到 plugins 页签 —— 解析器与源码形态不同步,本判据此刻是假绿") +} + +// tabsSpec 是从 dashboard.js 源码解析出的 TABS 条目。 +type tabsSpec struct { + name string + fetch []string + renders []string +} + +// parseTabs 用**花括号配平扫描**解析 TABS 对象。 +// +// 为什么不用正则切条目(实测踩过):单行条目 +// `starmap: { fetch: [], render: [...] },` 的右花括号在同一行, +// 非贪婪正则于是吞到**下一个**多行条目末尾 —— `starmap` 把紧随其后的 +// `plugins` 整块吞进自己体内,症状是 plugins 页签"不存在"。 +func parseTabs(t *testing.T, js string) []tabsSpec { + t.Helper() + start := strings.Index(js, "var TABS = {") + if start == -1 { + t.Fatal("找不到 var TABS") + } + openBrace := start + strings.Index(js[start:], "{") + + // 配平 TABS 主体。 + d := 0 + end := -1 + for i := openBrace; i < len(js); i++ { + switch js[i] { + case '{': + d++ + case '}': + d-- + if d == 0 { + end = i + } + } + if end >= 0 { + break + } + } + if end < 0 { + t.Fatal("TABS 花括号配平失败") + } + obj := js[openBrace+1 : end] + + var out []tabsSpec + i := 0 + for i < len(obj) { + // 跳过空白与逗号/换行,停在下一个标识符。 + for i < len(obj) && (obj[i] == ' ' || obj[i] == '\t' || obj[i] == '\n' || obj[i] == '\r' || obj[i] == ',') { + i++ + } + k := i + for k < len(obj) && (obj[k] == '_' || obj[k] >= 'a' && obj[k] <= 'z' || + obj[k] >= 'A' && obj[k] <= 'Z' || obj[k] >= '0' && obj[k] <= '9') { + k++ + } + if k == i { + i++ + continue + } + name := obj[i:k] + r := k + for r < len(obj) && (obj[r] == ' ' || obj[r] == '\t' || obj[r] == '\n' || obj[r] == '\r') { + r++ + } + if r >= len(obj) || obj[r] != ':' { + i = k + continue + } + r++ + for r < len(obj) && (obj[r] == ' ' || obj[r] == '\t' || obj[r] == '\n' || obj[r] == '\r') { + r++ + } + if r >= len(obj) || obj[r] != '{' { + i = k + continue + } + dd := 0 + close_ := -1 + for j := r; j < len(obj); j++ { + switch obj[j] { + case '{': + dd++ + case '}': + dd-- + if dd == 0 { + close_ = j + } + } + if close_ >= 0 { + break + } + } + if close_ < 0 { + t.Fatalf("条目 %q 花括号配平失败", name) + } + body := obj[r : close_+1] + spec := tabsSpec{name: name} + if f := quotedList(body, "fetch:"); f != nil { + spec.fetch = f + } + if rr := quotedList(body, "render:"); rr != nil { + spec.renders = rr + } + out = append(out, spec) + i = close_ + 1 + } + if len(out) == 0 { + t.Fatal("TABS 解析出 0 个页签 —— 解析器与源码形态不同步") + } + return out +} + +// quotedList 取 `key: ["a", "b"]` 里的字符串列表。 +func quotedList(body, key string) []string { + idx := strings.Index(body, key) + if idx == -1 { + return nil + } + lb := strings.Index(body[idx:], "[") + if lb == -1 { + return nil + } + lb += idx + rb := strings.Index(body[lb:], "]") + if rb == -1 { + return nil + } + inner := body[lb+1 : lb+rb] + var out []string + for _, p := range strings.Split(inner, ",") { + p = strings.Trim(strings.TrimSpace(p), `"`) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/plugins/webui/proxy.go b/internal/plugins/webui/proxy.go index 2a506c24..c230dbbf 100644 --- a/internal/plugins/webui/proxy.go +++ b/internal/plugins/webui/proxy.go @@ -159,6 +159,73 @@ func proxyHostLabel(host, base string) string { return label } +// proxyBaseDomains 返回**全部**应当被当子域基座接受的基域,按优先级排列。 +// +// ## 为什么需要多个(2026-11-04 生产实测缺陷) +// +// 生成链接与路由匹配之前用了**两个不同的源**: +// +// 生成链接 resolveEntry → base_url 的主机名 → huawei-smarthome.homeagent.jianfgit.xyz +// 路由匹配 proxyBaseDomain(settings) → base_domain → localhost +// +// 生产实例上 base_url=https://homeagent.jianfgit.xyz、base_domain=localhost, +// 于是面板里给出的子域链接**100% 进不了反代**(落到门户 mux → 302 /login), +// 而按 localhost 手拼的链接却能进反代。用户看到的是「点开是登录页」。 +// +// 修法不是二选一(改成只认 base_url 会让 *.localhost 失效,而那是 RFC 6761 +// 的零配置基座),而是**两者都认**:明确列出本次部署可能用到的基域。 +// +// 顺序有意义:configBase 在前(用户显式配置的 base_domain), +// 其次是 base_url 的主机名,最后回落到 localhost。 +func proxyBaseDomains(settings sdk.SettingsAPI) []string { + var out []string + seen := map[string]bool{} + add := func(d string) { + d = strings.Trim(strings.ToLower(strings.TrimSpace(d)), ".") + if d == "" || seen[d] { + return + } + seen[d] = true + out = append(out, d) + } + + configured := "" + if settings != nil { + if v, err := settings.Get("base_domain"); err == nil && v != nil { + if s, ok := v.(string); ok { + configured = strings.Trim(strings.ToLower(strings.TrimSpace(s)), ".") + } + } + } + add(configured) + + // base_url 的主机名:外部入口是 homeagent.example.com 时, + // 插件服务自然是 <标签>.homeagent.example.com。面板生成的链接正是这个, + // 所以路由**必须**认它,否则链接与路由分叉。 + if bu := baseURLSetting(settings); bu != "" { + if u, err := url.Parse(bu); err == nil && u.Hostname() != "" { + add(u.Hostname()) + } + } + + add("localhost") + return out +} + +// proxyHostLabelAny 在多个基域中任一个匹配时返回子域标签。 +// +// 为什么不是「先算 base 再调用 proxyHostLabel」:基域是**一组**候选, +// 调用方(serveProxyHost)不该自己拼循环,否则它一改就会与生成链接的 +// 逻辑再次分叉 —— 那正是本次缺陷的形成方式。 +func proxyHostLabelAny(host string, bases []string) string { + for _, b := range bases { + if label := proxyHostLabel(host, b); label != "" { + return label + } + } + return "" +} + // buildProxyTable 由「插件声明 + 手填条目」构建路由表。 // // 冲突与非法条目的处理原则:**宁可报出来,不可静默丢弃**。被拒绝的条目 @@ -359,6 +426,28 @@ func newReverseProxy(u *url.URL, auth, stripPrefix string) *httputil.ReverseProx resp.Header.Add("Set-Cookie", c.String()) } } + // ★ 前缀模式下必须把上游的**根绝对路径重定向**改回挂载前缀下。 + // + // 实测(2026-11-04,NapCat): + // GET 上游 / → 301 Location: /webui + // 浏览器跟随 → https://<门户>/webui ← 已经跑出 /napcat 挂载点 + // 门户 mux 接住 → 302 /login ← 用户看到登录页,以为是认证问题 + // + // 这就是「路径形态白屏」的直接成因。httputil.ReverseProxy + // **不**改写 Location(实测反向确认过),必须自己做。 + // + // 只改**以 / 开头的相对绝对路径**: + // - "http://..." 绝对 URL 可能指向上游之外,原样保留; + // - 已经是 stripPrefix+"/" 开头的(上游自己就带了)不重复加, + // 否则重定向会变成 /napcat/napcat/webui; + // - 空值与相对路径(不带前导 /)原样保留。 + if stripPrefix != "" { + if loc := resp.Header.Get("Location"); strings.HasPrefix(loc, "/") && + !strings.HasPrefix(loc, "//") && + !(loc == stripPrefix || strings.HasPrefix(loc, stripPrefix+"/")) { + resp.Header.Set("Location", stripPrefix+loc) + } + } return nil }, } @@ -551,6 +640,14 @@ func (h *Handler) serveProxyHost(w http.ResponseWriter, r *http.Request) bool { if h.settings != nil { base = proxyBaseDomain(h.settings) } + // ★ 路由匹配必须接受**全部**候选基域,不能只认 base_domain。 + // + // 生成链接用的是 base_url 的主机名(resolveEntry),而这里曾只认 + // base_domain ⇒ 生产实例 base_url=homeagent.jianfgit.xyz / + // base_domain=localhost 时,面板给出的子域链接 **100% 进不了反代** + // (落到门户 mux → 302 /login),而手拼 *.localhost 却能进。 + // 用户看到的是「点开是登录页」。详见 proxyBaseDomains 的说明。 + bases := proxyBaseDomains(h.settings) // ★ 发现端点必须先于路径挂载判定。 // // 否则它会被 /api/v1/device 这类前缀接走:声明该前缀的服务通常 @@ -603,7 +700,7 @@ func (h *Handler) serveProxyHost(w http.ResponseWriter, r *http.Request) bool { return true } - label := proxyHostLabel(r.Host, base) + label := proxyHostLabelAny(r.Host, bases) if label == "" { return false } diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index 560ca1bc..b089a222 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -437,6 +437,14 @@ func New(name string, cfg SDKConfig) *PluginSDK { // 反代声明收集:内置插件(无 plugin.json)在 Start 里用 RegisterProxy // 声明自己的服务,落到本包的登记表;外部插件走 plugin.json 自动发现。 base.SetProxyRegistrar(func(svc string, d pubsdk.ProxyDef) { RegisterBuiltinProxy(name, svc, d) }) + // 告警钩子:内置插件路径不会用到它;它真正的用处在插件进程侧 + // (外部插件的 RegisterProxy 没有回调可落,声明会静默消失)。 + // 这里也注入一份,使**任何**走到本 SDK 实例却无处落地的声明都留痕。 + base.SetProxyRegistrarMissing(func(svc string, d pubsdk.ProxyDef) { + log.Printf("[sdk] %s 的 RegisterProxy(%q) 未生效:当前无注册回调。"+ + "外部插件请在 plugin.json 的 proxies 里声明(host=%q target=%q)", + name, svc, d.Host, d.Target) + }) if cfg.IOManager != nil { base.SetIOInjector(ioAdapter{iom: cfg.IOManager}) } diff --git a/third_party/hologramagentsdk/sdk/plugin.go b/third_party/hologramagentsdk/sdk/plugin.go index 73bbd307..a04e1bcb 100644 --- a/third_party/hologramagentsdk/sdk/plugin.go +++ b/third_party/hologramagentsdk/sdk/plugin.go @@ -638,6 +638,11 @@ type PluginSDK struct { // 起的 goroutine。 proxyReg ProxyRegistrar + // proxyWarn 是「RegisterProxy 调用但无注册回调」时的告警钩子。 + // 外部插件(子进程)就是这种情况 —— 见 RegisterProxy 的说明。 + // 未注入时为 nil(工具链/单测保持安静)。 + proxyWarn ProxyRegistrarMissing + // apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。 // // 这些字段的写方与读方天然跨 goroutine: diff --git a/third_party/hologramagentsdk/sdk/proxy.go b/third_party/hologramagentsdk/sdk/proxy.go index 2eb467b9..8e94946e 100644 --- a/third_party/hologramagentsdk/sdk/proxy.go +++ b/third_party/hologramagentsdk/sdk/proxy.go @@ -22,8 +22,25 @@ import ( // 插件、或旧 HologramAgent 读新插件都不会报错。 // // 与 ToolDef / ChannelDef / ConfigDef 同族:SDK 定义声明契约,内核实现行为。 -// 声明方式与其它能力一致 —— 在 Start() 里调 RegisterProxy(name, def), -// 或写进 plugin.json 的 proxies 字段(外部插件两种都支持)。 +// +// # 两种声明入口,以及各自**谁能用**(重要) +// +// - **plugin.json 的 proxies 字段**:这是**外部插件唯一的入口**。 +// 由 HologramAgent 加载插件时读取 plugin.json 聚合,不经过插件进程的 SDK。 +// +// - **RegisterProxy(name, def)**:**仅对内置插件有效**(编译进内核、 +// 没有插件目录与 plugin.json 的那些,如 remotedevice)。 +// 外部插件(独立 plugin.bin 子进程)调它**不会生效**,也不会报错。 +// +// 为什么外部插件不能用运行期注册:插件跑在子进程里,声明要跨 RPC 协议回传 +// 才可能落地,而 proc 协议目前**没有** proxy 注册方法(grep "proxy" in +// internal/plugin/proc/protocol.go 零命中),生成到插件工程的运行时模板也 +// 不注入该注册回调(同 grep 零命中)。所以外部插件的 RegisterProxy 是 +// 一个**空实现**。 +// +// 历史提醒:本文件早期版本写的是「外部插件两种都支持」,那是错的, +// 造成了“声明了却没被反代、排查时只查端口”的真实困境。若将来要让运行期 +// 声明对外部插件生效,必须先补协议,而不是只把这句话改回去。 // // 安全性:**不声明 = 不被反代**。声明本身就是能力声明,因此不需要在 // capabilities 里另外开一个开关——最小权限默认生效。 @@ -328,6 +345,28 @@ func ValidateProxyDef(d ProxyDef) string { // - 内置插件 → RegisterProxy(运行期,随 Start 注册) type ProxyRegistrar func(name string, def ProxyDef) +// ProxyRegistrarMissing 是「RegisterProxy 被调用、但没有注册回调」时的告警钩子。 +// +// 存在理由:外部插件跑在子进程里,内核从未向它注入 ProxyRegistrar +// (proc 协议无 proxy 注册方法),于是它的 RegisterProxy 是空实现。 +// 不告警的话,声明就**彻底静默消失** —— 用户只能看到“声明了却没被反代”。 +// +// sdk 包不直接打日志(它被插件进程与工具链复用,不应绑定日志实现), +// 由内核侧通过 SetProxyRegistrarMissing 注入。未注入时保持完全安静 +// (工具链、单元测试不需要噪声)。 +type ProxyRegistrarMissing func(name string, def ProxyDef) + +// SetProxyRegistrarMissing 由内核注入「声明无处落地」的告警实现。 +// 插件不调用它。 +func (s *PluginSDK) SetProxyRegistrarMissing(fn ProxyRegistrarMissing) { + if s == nil { + return + } + s.apiMu.Lock() + s.proxyWarn = fn + s.apiMu.Unlock() +} + // SetProxyRegistrar 由内核注入。插件不直接调它(与 SetInputChannelRegistrar 同族)。 func (s *PluginSDK) SetProxyRegistrar(r ProxyRegistrar) { if s == nil { @@ -350,19 +389,42 @@ func (s *PluginSDK) SetProxyRegistrar(r ProxyRegistrar) { // Host: "myapp", Target: "127.0.0.1:12100", // }) // -// 声明立即生效(反代表在下一次请求时重建)。**不做去重**:同一 Host/Path +// ## ⚠ 仅对内置插件有效 +// +// 插件跑在**子进程**里时(外部插件 / plugin.bin),本方法没有任何效果: +// 内核从未向插件进程注入 proxyReg 回调(proc 协议里没有 proxy 注册方法)。 +// 调用会静默返回,不报错。 +// +// **外部插件请写 plugin.json 的 proxies 字段** —— 那是唯一生效的入口。 +// 详见包注释的「两种声明入口,以及各自谁能用」。 +// +// 内置插件(编译进内核、无 plugin.json)用本方法声明,立即生效 +// (反代表在下一次请求时重建)。**不做去重**:同一 Host/Path // 被两条声明占用时由反代层判定冲突并明确报错,而不是在这里静默吞掉 —— // 插件作者需要看见冲突。 -// -// 与 plugin.json 的 proxies 字段等价:写哪个都行,两者会合并(同名以本调用为准)。 func (s *PluginSDK) RegisterProxy(name string, def ProxyDef) { if s == nil { return } s.apiMu.RLock() r := s.proxyReg + warn := s.proxyWarn s.apiMu.RUnlock() if r != nil { r(name, def) + return + } + // ★ 没有注册回调时必须**留痕**,不能静默丢弃。 + // + // 外部插件(独立 plugin.bin 子进程)就是这样:内核从未向插件进程注入 + // proxyReg(proc 协议无 proxy 注册方法),所以本调用永远走不到上面 + // 那个分支。历史实测代价:插件作者按旧文档写的声明完全没生效, + // 排查时只想到端口/防火墙/base_domain —— 因为**没有任何信号**说 + // 「声明根本没发出去」。 + // + // 这里只调可选钩子而不直接打日志:sdk 包不依赖 log(它被插件进程与 + // 工具链复用),日志实现由内核侧注入。 + if warn != nil { + warn(name, def) } }