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:
JianFeeeee
2026-11-04 17:15:43 +08:00
parent 0c959e3ff3
commit 07d35297a6
6 changed files with 489 additions and 7 deletions

View File

@ -638,6 +638,11 @@ type PluginSDK struct {
// 起的 goroutine。
proxyReg ProxyRegistrar
// proxyWarn 是「RegisterProxy 调用但无注册回调」时的告警钩子。
// 外部插件(子进程)就是这种情况 —— 见 RegisterProxy 的说明。
// 未注入时为 nil(工具链/单测保持安静)。
proxyWarn ProxyRegistrarMissing
// apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。
//
// 这些字段的写方与读方天然跨 goroutine:

View File

@ -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)
}
}