mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
fix(webui): 反代三处缺陷 —— 入口不显示、子域链接失效、前缀重定向逃逸
全部来自本机 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,那是运维决策,不在本次改动内。
This commit is contained in:
@ -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"] },
|
||||
|
||||
302
internal/plugins/webui/dashboard_tabs_deps_test.go
Normal file
302
internal/plugins/webui/dashboard_tabs_deps_test.go
Normal file
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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})
|
||||
}
|
||||
|
||||
5
third_party/hologramagentsdk/sdk/plugin.go
vendored
5
third_party/hologramagentsdk/sdk/plugin.go
vendored
@ -638,6 +638,11 @@ type PluginSDK struct {
|
||||
// 起的 goroutine。
|
||||
proxyReg ProxyRegistrar
|
||||
|
||||
// proxyWarn 是「RegisterProxy 调用但无注册回调」时的告警钩子。
|
||||
// 外部插件(子进程)就是这种情况 —— 见 RegisterProxy 的说明。
|
||||
// 未注入时为 nil(工具链/单测保持安静)。
|
||||
proxyWarn ProxyRegistrarMissing
|
||||
|
||||
// apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。
|
||||
//
|
||||
// 这些字段的写方与读方天然跨 goroutine:
|
||||
|
||||
72
third_party/hologramagentsdk/sdk/proxy.go
vendored
72
third_party/hologramagentsdk/sdk/proxy.go
vendored
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user