feat(webui): 路径挂载的 strip_path 两态 + 尾斜杠重定向(修 /p/huawei 打不开数据)

用户要求用方案 A(路径挂载)让 huawei 插件 UI 在外部可用,
并把「通过反代的插件必须使用单一入口」写入 SDK 声明。

## 实测暴露的两个真问题

1. **Path 的语义不能一刀切**。原设计「原样保留」只对**机器接口**成立
   (设备客户端硬编码 /api/v1/device/ws,不可能知道反代的存在);
   而自带 UI 的服务需要**剥掉前缀**(/p/huawei/api/status → 上游 /api/status)。
   猜错的结果是全部请求 404,且看起来像上游故障 —— 所以由声明者选:
   strip_path=false 别名模式 / true 前缀模式。非法组合被 validate 挡住。

2. **前缀模式的尾斜杠是必需的**(自测发现的 bug)。
   访问 /p/huawei(无尾斜杠)时页面能开,但页面里所有 fetch 都 404 ——
   相对路径以「当前文档目录」为基准,没尾斜杠时浏览器把最后一段当文件名,
   目录退回上一级,fetch('api/status') 打到 /p/api/status。
   修:前缀模式且路径恰等于前缀时 301 到 /p/huawei/(保留查询串)。
   **别名模式不做此事** —— 那类路径是上游真实语义,加斜杠会改坏它。

## 插件侧(huawei_smarthome)

- 前端 4 处根绝对路径(fetch('/api/status') 等)改为相对路径,
  基准由 location.pathname 推导(BASE)。这是 Path 形态能成立的**前提** ——
  否则请求会打到门户自己身上。
- plg.json 声明:host + path=/p/huawei + strip_path=true + auth=homeagent。
- SDK 升到 1.4.0,并用 hmapdev 1.4.0 重新打包(1.3.0 的 hmapdev 无
  proxies 支持,会把声明**静默丢弃** —— 实测确认过,这是打包链路上
  一个不报警的坑,值得记住)。

## 判据

+6 条:TestProxyPathAliasVsStrip(两态各自正确)、
TestProxyPathLongestPrefixWins(/p/app 不得劫持 /p/apple,
且长前缀胜出)、TestProxyStripPathRedirectsToTrailingSlash(尾斜杠,
含查询串保留 + 别名模式不得重定向)。

变异验证(4 条,均按预期打红后还原回绿):
- 删尾斜杠重定向 → 判红(还原了真实 bug 形态)
- 让别名模式也重定向 → 判红(设备网关语义被毁)
- 从 hmapdev schema 探测体删 StripPath → 判红(漂移检测有效)
- 删 SDK 里的「单一入口原则」字样 → 判红(契约不能只剩口头约定)

全量:35 包全绿。
This commit is contained in:
JianFeeeee
2026-09-25 17:00:26 +08:00
parent 5da0f8f9fb
commit 2c810bbbce
10 changed files with 556 additions and 162 deletions

View File

@ -322,7 +322,7 @@ func New(name string, cfg SDKConfig) *PluginSDK {
base := pubsdk.New(name, cfg.Settings, cfg.RegTool, cfg.RegStage, cfg.RegAPI, cfg.RegOutput)
// 反代声明收集:内置插件(无 plugin.json)在 Start 里用 DeclareProxy
// 声明自己的服务,落到本包的登记表;外部插件走 plugin.json 自动发现。
base.SetProxyDeclarer(func(d pubsdk.ProxyDecl) { DeclareBuiltinProxy(name, d) })
base.SetProxyRegistrar(func(svc string, d pubsdk.ProxyDef) { RegisterBuiltinProxy(name, svc, d) })
if cfg.IOManager != nil {
base.SetIOInjector(ioAdapter{iom: cfg.IOManager})
}

View File

@ -1,3 +1,13 @@
// Package sdk —— 反代声明的内核侧登记表。
//
// 契约定义在公开 SDK(pubsdk.ProxyDef)。本文件只做两件事:
//
// 1. 把公开类型重新导出,让内核侧调用方只需 import 本包。
// 2. 维护**内置插件**的声明表(它们没有 plugin.json,扫目录发现不了)。
//
// 外部插件的声明由反代层直接读 plugin.json 得到,不经过这里 —— 那份声明
// 是静态的、插件没启动也可见,而内置插件的声明只能运行期拿到。两种来源在
// 反代层合并(见 webui.buildProxyTable)。
package sdk
import (
@ -7,101 +17,93 @@ import (
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 反代声明在 **公开 SDK** 里定义(`pubsdk`),内核侧只是别名转发。
//
// 为什么放公开 SDK 而不是内核实现在:声明是**插件作者直接书写的契约**
// (plugin.json 的 proxies 字段),必须与 SDK 文档、hmapdev 工具链用同一套
// 定义与校验,否则插件作者本地通过、内核拒绝,或反之。
//
// 这与 ConfigDef 等信息完全同构——公开面定义契约,内核面实现行为。
// ProxyDef 是一条反代声明(见 pubsdk.ProxyDef 的完整文档,含「单一入口原则」)。
// 与 ConfigDef / ChannelDef / ToolDef 同族:SDK 定契约,内核实现行为。
type ProxyDef = pubsdk.ProxyDef
// ProxyDecl 是一条反代声明(见 pubsdk.ProxyDecl 的完整文档)。
type ProxyDecl = pubsdk.ProxyDecl
// 生效的鉴权模式取值。
// 鉴权与 Host 规范化的常量/函数转发(调用方不必两处 import)。
const (
// ProxyAuthHomeAgent:由 HomeAgent 统一保护(门户会话或 X-API-Key)。
ProxyAuthHomeAgent = pubsdk.ProxyAuthHomeAgent
// ProxyAuthNone:不经 HomeAgent 鉴权,信任上游自身鉴权。
ProxyAuthNone = pubsdk.ProxyAuthNone
ProxyAuthNone = pubsdk.ProxyAuthNone
)
// ValidProxyAuth 校验鉴权模式取值(空串合法,等价 ProxyAuthHomeAgent)。
func ValidProxyAuth(auth string) bool { return pubsdk.ValidProxyAuth(auth) }
// ValidateProxyDef 校验一条声明,返回人类可读的错误(合法时为空)。
func ValidateProxyDef(d ProxyDef) string { return pubsdk.ValidateProxyDef(d) }
// EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHomeAgent)。
// EffectiveProxyAuth 返回生效的鉴权模式(空串按默认 homeagent 处理)。
func EffectiveProxyAuth(auth string) string { return pubsdk.EffectiveProxyAuth(auth) }
// ValidProxyHostLabel 校验子域标签是否合法(DNS label 规则)。
// ValidProxyAuth 判断鉴权取值是否合法。
func ValidProxyAuth(auth string) bool { return pubsdk.ValidProxyAuth(auth) }
// NormalizeProxyHost 由插件名推导默认的 Host 标签。
func NormalizeProxyHost(plugin string) string { return pubsdk.NormalizeProxyHost(plugin) }
// ValidProxyHostLabel 校验子域名标签是否合法(DNS label 规则)。
func ValidProxyHostLabel(label string) bool { return pubsdk.ValidProxyHostLabel(label) }
// NormalizeProxyHost 由插件名派生默认的子域标签。
func NormalizeProxyHost(pluginName string) string { return pubsdk.NormalizeProxyHost(pluginName) }
// ValidateProxyDecl 校验一条声明,返回人类可读的错误(合法时为空)。
func ValidateProxyDecl(d ProxyDecl) string { return pubsdk.ValidateProxyDecl(d) }
// ---- 内置插件反代声明的运行期登记表 ----
// 为什么需要它:外部插件的声明在 plugin.json 里,可以扫目录发现;但**内置**
// 插件编译进内核、没有插件目录,靠扫盘永远发现不了自己的服务——而设备网关
// (remotedevice)正是内置的,且最需要被反代出去。两种来源互补。
// 内置插件(编译进内核、无 plugin.json)的反代声明表。
//
// 版本号 builtinProxyVer 每次变更自增。反代层据此判断缓存的路由表是否过期 ——
// 比每次请求都重新聚合一遍便宜得多。
var (
builtinProxyMu sync.RWMutex
builtinProxyDecls = map[string][]ProxyDecl{}
builtinProxyVer int64
builtinProxyMu sync.RWMutex
builtinProxyVer int64
builtinProxyDefs = map[string][]ProxyDef{}
)
// DeclareBuiltinProxy 登记一个内置插件的服务声明(由 DeclareProxy 转发)。
func DeclareBuiltinProxy(plugin string, d ProxyDecl) {
// RegisterBuiltinProxy 登记一个内置插件的服务声明(由 RegisterProxy 转发)。
//
// ProxyDef.Name 由调用方保证非空(RegisterProxy 会在缺失时兜底为 "service")。
func RegisterBuiltinProxy(plugin, name string, d ProxyDef) {
if plugin == "" || strings.TrimSpace(d.Target) == "" {
return
}
builtinProxyMu.Lock()
defer builtinProxyMu.Unlock()
// 同一插件同一声明名重复登记(如自动重启后再次 Start)视为刷新,不重复累积。
name := d.Name
if name == "" {
name = "service"
if d.Name == "" {
d.Name = name
}
list := builtinProxyDecls[plugin]
if d.Name == "" {
d.Name = "service"
}
builtinProxyMu.Lock()
defer builtinProxyMu.Unlock()
// 同名重复登记(如自动重启后再次 Start)视为刷新,不重复累积。
list := builtinProxyDefs[plugin]
for i := range list {
if list[i].Name == name {
if list[i].Name == d.Name {
list[i] = d
builtinProxyVer++
return
}
}
builtinProxyDecls[plugin] = append(list, d)
builtinProxyDefs[plugin] = append(list, d)
builtinProxyVer++
}
// ClearBuiltinProxyDecls 清除某插件的声明(插件停止/卸载时调用)。
func ClearBuiltinProxyDecls(plugin string) {
// ClearBuiltinProxyDefs 清除某插件的声明(插件停止/卸载时调用)。
func ClearBuiltinProxyDefs(plugin string) {
builtinProxyMu.Lock()
defer builtinProxyMu.Unlock()
if _, ok := builtinProxyDecls[plugin]; ok {
delete(builtinProxyDecls, plugin)
if _, ok := builtinProxyDefs[plugin]; ok {
delete(builtinProxyDefs, plugin)
builtinProxyVer++
}
}
// BuiltinProxyDecls 返回内置插件声明的快照(plugin → decls)。
func BuiltinProxyDecls() map[string][]ProxyDecl {
// BuiltinProxyDefs 返回内置声明的快照(plugin → defs)。
func BuiltinProxyDefs() map[string][]ProxyDef {
builtinProxyMu.RLock()
defer builtinProxyMu.RUnlock()
out := make(map[string][]ProxyDecl, len(builtinProxyDecls))
for k, v := range builtinProxyDecls {
cp := make([]ProxyDecl, len(v))
out := make(map[string][]ProxyDef, len(builtinProxyDefs))
for k, v := range builtinProxyDefs {
cp := make([]ProxyDef, len(v))
copy(cp, v)
out[k] = cp
}
return out
}
// BuiltinProxyVersion 是声明表的版本号。调用方(webui 反代层)据它判断
// 缓存的路由表是否过期——比每次请求重新聚合一遍便宜得多。
// BuiltinProxyVersion 是声明表的版本号,供反代层判断缓存是否过期。
func BuiltinProxyVersion() int64 {
builtinProxyMu.RLock()
defer builtinProxyMu.RUnlock()

View File

@ -4,18 +4,18 @@ import "testing"
// 内置插件的运行期声明必须真的被登记、可枚举、可清除——
// remotedevice(内置、无 plugin.json)就靠这条通道。
func TestBuiltinProxyDeclRegistry(t *testing.T) {
func TestBuiltinProxyDefRegistry(t *testing.T) {
const p = "test_builtin_proxy"
ClearBuiltinProxyDecls(p)
defer ClearBuiltinProxyDecls(p)
ClearBuiltinProxyDefs(p)
defer ClearBuiltinProxyDefs(p)
before := BuiltinProxyVersion()
DeclareBuiltinProxy(p, ProxyDecl{Name: "gw", Host: "devices", Target: "127.0.0.1:9890", WebSocket: true, Auth: ProxyAuthNone})
RegisterBuiltinProxy(p, "gw", ProxyDef{Name: "gw", Host: "devices", Target: "127.0.0.1:9890", WebSocket: true, Auth: ProxyAuthNone})
if BuiltinProxyVersion() == before {
t.Error("登记后版本号应递增(反代层靠它判断缓存失效)")
}
got := BuiltinProxyDecls()
got := BuiltinProxyDefs()
list := got[p]
if len(list) != 1 {
t.Fatalf("登记了 %d 条,期望 1: %+v", len(list), got)
@ -25,31 +25,39 @@ func TestBuiltinProxyDeclRegistry(t *testing.T) {
}
// 重复登记同名(如自动重启后再次 Start)应为刷新而非累积
DeclareBuiltinProxy(p, ProxyDecl{Name: "gw", Host: "devices", Target: "127.0.0.1:9890", WebSocket: true, Auth: ProxyAuthNone})
if l := BuiltinProxyDecls()[p]; len(l) != 1 {
RegisterBuiltinProxy(p, "gw", ProxyDef{Name: "gw", Host: "devices", Target: "127.0.0.1:9890", WebSocket: true, Auth: ProxyAuthNone})
if l := BuiltinProxyDefs()[p]; len(l) != 1 {
t.Errorf("重复登记应为刷新,实际累积成 %d 条", len(l))
}
// 空 target 必须被拒(不声明的默认就是不被反代,空声明更不该登记)
DeclareBuiltinProxy(p, ProxyDecl{Name: "bad", Target: " "})
if l := BuiltinProxyDecls()[p]; len(l) != 1 {
RegisterBuiltinProxy(p, "bad", ProxyDef{Name: "bad", Target: " "})
if l := BuiltinProxyDefs()[p]; len(l) != 1 {
t.Errorf("空 target 不应被登记,实际 %d 条", len(l))
}
ClearBuiltinProxyDecls(p)
if _, ok := BuiltinProxyDecls()[p]; ok {
ClearBuiltinProxyDefs(p)
if _, ok := BuiltinProxyDefs()[p]; ok {
t.Error("清除后不应还有该插件的声明")
}
}
// DeclareProxy 必须把声明转发给内核注入的收集回调。
func TestDeclareProxyForwards(t *testing.T) {
var got []ProxyDecl
// RegisterProxy 必须把声明转发给内核注入的注册回调,且把 name 落进 def
// (与 RegisterTool 的风格一致:name 同时来自参数与 def.Name)。
func TestRegisterProxyForwards(t *testing.T) {
var gotName string
var got []ProxyDef
ps := New("demo", SDKConfig{})
ps.SetProxyDeclarer(func(d ProxyDecl) { got = append(got, d) })
ps.SetProxyRegistrar(func(name string, d ProxyDef) {
gotName = name
got = append(got, d)
})
ps.DeclareProxy(ProxyDecl{Name: "ui", Host: "demo", Target: "127.0.0.1:12100", WebSocket: false, Auth: ProxyAuthNone})
ps.RegisterProxy("ui", ProxyDef{Name: "ui", Host: "demo", Target: "127.0.0.1:12100", Auth: ProxyAuthNone})
if len(got) != 1 || got[0].Host != "demo" || got[0].Auth != ProxyAuthNone {
t.Fatalf("声明未转发到收集回调: %+v", got)
t.Fatalf("声明未转发到注册回调: %+v", got)
}
if gotName != "ui" {
t.Errorf("name 参数未透传: %q", gotName)
}
}