mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 21:33:05 +00:00
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:
2
third_party/homeagent-sdk/sdk/plugin.go
vendored
2
third_party/homeagent-sdk/sdk/plugin.go
vendored
@ -356,7 +356,7 @@ type PluginSDK struct {
|
||||
// proxyDecl 是反代声明的收集回调(内置插件经 DeclareProxy 声明服务)。
|
||||
// 与上面的 API 字段同受 apiMu 保护——写方是内核注入,读方是插件 Start
|
||||
// 起的 goroutine。
|
||||
proxyDecl ProxyDeclarer
|
||||
proxyReg ProxyRegistrar
|
||||
|
||||
// apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。
|
||||
//
|
||||
|
||||
131
third_party/homeagent-sdk/sdk/proxy.go
vendored
131
third_party/homeagent-sdk/sdk/proxy.go
vendored
@ -21,18 +21,51 @@ import (
|
||||
// 3. 旧内核无害:manifest 解析忽略未知字段,未支持该能力的 HomeAgent 读旧
|
||||
// 插件、或旧 HomeAgent 读新插件都不会报错。
|
||||
//
|
||||
// 与 ToolDef / ChannelDef / ConfigDef 同族:SDK 定义声明契约,内核实现行为。
|
||||
// 声明方式与其它能力一致 —— 在 Start() 里调 RegisterProxy(name, def),
|
||||
// 或写进 plugin.json 的 proxies 字段(外部插件两种都支持)。
|
||||
//
|
||||
// 安全性:**不声明 = 不被反代**。声明本身就是能力声明,因此不需要在
|
||||
// capabilities 里另外开一个开关——最小权限默认生效。
|
||||
//
|
||||
// 反代路径:HomeAgent 按 Host 路由(子域名标签 → Target),而非路径前缀。
|
||||
// 理由:插件前端普遍使用根绝对路径(`fetch('/api/status')`),放在路径前缀
|
||||
// 下会被劫持到 HomeAgent 自己的路由上;Host 路由下根路径天然正确,
|
||||
// 插件前端**零改动**。这也让「只穿透一个端口」成立:同一端口按 Host 分发。
|
||||
type ProxyDecl struct {
|
||||
// # 单一入口原则(强制要求)
|
||||
//
|
||||
// **一个声明 = 一个入口**。被反代的插件必须让它的全部资源与接口都能从
|
||||
// 该入口的一个基准路径出发访问到,不得依赖「入口之外的根路径」。
|
||||
//
|
||||
// 为什么强制:反代有两种挂载形态,而它们对「根路径」的处理截然不同——
|
||||
//
|
||||
// Host 形态(host):插件独占 <标签>.<基域名>,根路径就是插件的根。
|
||||
// 根绝对路径(fetch('/api/x'))**天然正确**。
|
||||
// Path 形态(path):插件挂在门户自身 host 的某个前缀下,根路径属于**门户**。
|
||||
// 此时插件里的 fetch('/api/x') 会打到门户自己的 /api/x
|
||||
// —— 静默错路由,页面能开但功能全坏。
|
||||
//
|
||||
// 于是「同一个插件必须同时支持两种形态」这条要求,等价于:
|
||||
//
|
||||
// **插件内部一律使用相对路径**(或基于 <base>/location 推导的路径),
|
||||
// 绝不硬编码以 / 开头的绝对路径。
|
||||
//
|
||||
// 这样同一份前端在两种形态下都正确,插件作者也不必知道自己被挂在哪。
|
||||
// 反代层据此可以:外部子域可用时给 Host 形态,子域不可用(证书/放行限制)
|
||||
// 时给 Path 形态,**无需插件配合改动**。
|
||||
//
|
||||
// 自检(插件作者在本地就该做):把页面挂到 <门户>/<任意前缀>/ 下访问,
|
||||
// 所有请求都必须仍然打到插件自己。
|
||||
//
|
||||
// 本项目实测案例:某插件前端写死 fetch('/api/status'),配在
|
||||
// /p/huawei/ 下会打到门户的 /api/status(404 或返回门户数据);
|
||||
// 改成相对路径后两种形态同时可用。
|
||||
// ProxyDef 是一个服务的**反代声明体**。
|
||||
//
|
||||
// 与 ToolDef 同构:Name 同时出现在字段与 RegisterProxy 的第一个参数里
|
||||
// (ToolDef 也是这么做的 —— 字段供 plugin.json 序列化,参数供运行期调用)。
|
||||
// Name 只用于展示、日志与冲突提示,**不参与路由**(路由键是 Host 与 Path)。
|
||||
type ProxyDef struct {
|
||||
// Name 是同一插件内多条声明的唯一标识(如 "ui"、"api")。
|
||||
// 省略时由 HomeAgent 按声明顺序补 "default"/"ui"/"api"... 仅用于展示与日志。
|
||||
// 运行期由 RegisterProxy 的第一个参数填入;声明式由 plugin.json 的
|
||||
// name 键填入。省略时由 HomeAgent 兜底为 "service"。
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Host 是**子域名标签**(不含基域名),如 "huawei" 对应 huawei.<基域名>。
|
||||
//
|
||||
// 约束:仅小写字母、数字与连字符,不以连字符开头/结尾,长度 ≤ 63
|
||||
@ -69,10 +102,34 @@ type ProxyDecl struct {
|
||||
// 例如上游注册 /api/v1/device/ws,就声明 Path="/api/v1/device"。
|
||||
// 这样设备客户端可以直接使用它已硬编码的路径,不需要知道反代的存在。
|
||||
//
|
||||
// 与 Host 形态的关系(见包注释的「单一入口原则」):声明的服务应当
|
||||
// **同时**能被两种形态访问。因此 Path 形态下插件内部必须用相对路径,
|
||||
// 否则它的前端会把请求打到门户自己身上。
|
||||
//
|
||||
// 留空 = 只提供子域形态(插件自带 UI 的常见情形:UI 与它自己的 API
|
||||
// 同源,走子域天然正确)。
|
||||
Path string `json:"path,omitempty"`
|
||||
|
||||
// StripPath 决定转发前是否**剥掉** Path 前缀。默认 false(原样保留)。
|
||||
//
|
||||
// 两种挂载语义真实不同,必须由声明者选,不能靠猜:
|
||||
//
|
||||
// false(别名模式):Path 就是上游真实路径的一部分。
|
||||
// 请求 /api/v1/device/ws + Path="/api/v1/device"
|
||||
// → 上游收到 /api/v1/device/ws(一模一样)。
|
||||
// 适用:客户端**已硬编码**路径的机器接口(设备网关就是如此,
|
||||
// 它按 /api/v1/device/ws 连接,不可能知道反代的存在)。
|
||||
//
|
||||
// true(前缀模式):Path 只是门户上的挂载点,上游不知道它。
|
||||
// 请求 /p/myapp/api/status + Path="/p/myapp"
|
||||
// → 上游收到 /api/status。
|
||||
// 适用:自带 UI 的服务(前端用相对路径,被挂到哪里都对)。
|
||||
//
|
||||
// 为什么不能自动判定:同一个声明「Path=/api/v1/device」在两种语义下
|
||||
// 都说得通,代理无从分辨 —— 猜错的结果是全部请求 404,且看起来像
|
||||
// 上游故障。所以由声明者显式写清楚。
|
||||
StripPath bool `json:"strip_path,omitempty"`
|
||||
|
||||
// Auth 决定这条反代由谁保护,取值见 ProxyAuthNone / ProxyAuthHomeAgent。
|
||||
// 空串等价于 ProxyAuthHomeAgent(默认安全)。
|
||||
//
|
||||
@ -164,12 +221,12 @@ func NormalizeProxyHost(pluginName string) string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ValidateProxyDecl 校验一条反代声明,返回人类可读的错误说明(合法时为空)。
|
||||
// ValidateProxyDef 校验一条反代声明,返回人类可读的错误说明(合法时为空)。
|
||||
//
|
||||
// 为什么要在 SDK 里做校验:HomeAgent 加载插件时必须能明确拒绝坏声明并说明
|
||||
// 原因(而不是静默忽略导致用户以为配好了);插件作者也需要在本地就能查出
|
||||
// 拼错的 Target/Host。同一套规则两端共用。
|
||||
func ValidateProxyDecl(d ProxyDecl) string {
|
||||
func ValidateProxyDef(d ProxyDef) string {
|
||||
if strings.TrimSpace(d.Target) == "" {
|
||||
return "target 为空:必须给出上游地址(如 127.0.0.1:12100 或 http://127.0.0.1:12100)"
|
||||
}
|
||||
@ -190,6 +247,12 @@ func ValidateProxyDecl(d ProxyDecl) string {
|
||||
return "path 含非法字符: " + d.Path
|
||||
}
|
||||
}
|
||||
// 前缀模式必须给出可剥的前缀。
|
||||
// 注意 "/" 不需要单独判:它是前缀又同时以 "/" 结尾,已被上面的
|
||||
// 「不应以 / 结尾」规则挡掉(挂到门户根会覆盖整站的意图因此无法达成)。
|
||||
if d.StripPath && strings.TrimSpace(d.Path) == "" {
|
||||
return "strip_path=true 时必须给出 path(否则没有可剥的前缀)"
|
||||
}
|
||||
// Target 的 host:port 部分必须可解析;路径前缀允许保留。
|
||||
//
|
||||
// 规则(刻意从严,因为地址写错是最常见的声明错误,而错误的反代会把
|
||||
@ -232,45 +295,51 @@ func ValidateProxyDecl(d ProxyDecl) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ProxyDeclarer 是内核注入的「收集反代声明」回调。
|
||||
// ProxyRegistrar 由内核注入(与 ToolRegistrar / InputChannelRegistrar 同族)。
|
||||
// 插件不直接调它,用 RegisterProxy。
|
||||
//
|
||||
// 为什么需要运行期通道(明明主要走 plugin.json 自动发现):**内置插件**
|
||||
// (编译进内核、没有独立插件目录与 plugin.json,如 remotedevice)无法靠
|
||||
// 扫目录发现自己的服务;而它们恰恰最需要被反代出去(设备网关就是内置的)。
|
||||
// 两种来源互补:
|
||||
// - 外部插件 → plugin.json 的 proxies(静态、未启动也可见)
|
||||
// - 内置插件 → DeclareProxy(运行期,随 Start 注册)
|
||||
type ProxyDeclarer func(decl ProxyDecl)
|
||||
// 为什么需要运行期注册(明明有 plugin.json 自动发现):**内置插件**
|
||||
// (编译进内核、没有独立插件目录与 plugin.json,如 remotedevice)扫不到;
|
||||
// 而它们恰恰最需要被反代出去(设备网关就是内置的)。两种来源互补:
|
||||
// - 外部插件 → plugin.json 的 proxies(静态,未启动也可见)
|
||||
// - 内置插件 → RegisterProxy(运行期,随 Start 注册)
|
||||
type ProxyRegistrar func(name string, def ProxyDef)
|
||||
|
||||
// SetProxyDeclarer 由内核注入收集回调。插件不直接调它。
|
||||
func (s *PluginSDK) SetProxyDeclarer(d ProxyDeclarer) {
|
||||
// SetProxyRegistrar 由内核注入。插件不直接调它(与 SetInputChannelRegistrar 同族)。
|
||||
func (s *PluginSDK) SetProxyRegistrar(r ProxyRegistrar) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.apiMu.Lock()
|
||||
s.proxyDecl = d
|
||||
s.proxyReg = r
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
|
||||
// DeclareProxy 声明本插件的一个服务需要 HomeAgent 反代出去。
|
||||
// RegisterProxy 声明一个需要 HomeAgent 反代出去的服务。
|
||||
//
|
||||
// 与 RegisterTool / RegisterInputChannel / RegisterOutputChannel 同一风格:
|
||||
// 显式给名字 + 声明体。名字用于展示、日志与冲突提示(不参与路由 —— 路由键是
|
||||
// def.Host / def.Path)。
|
||||
//
|
||||
// 用法(通常在 Start 里调用):
|
||||
//
|
||||
// s.DeclareProxy(sdk.ProxyDecl{
|
||||
// Name: "ui", Host: "myapp", Target: "127.0.0.1:12100",
|
||||
// s.RegisterProxy("ui", sdk.ProxyDef{
|
||||
// Host: "myapp", Target: "127.0.0.1:12100",
|
||||
// })
|
||||
//
|
||||
// 声明立即生效(反代表会在下一次请求时重建)。声明**不做去重**:同一 Host
|
||||
// 被两条声明占用时由反代层判定冲突并明确报错,而不是这里静默吞掉——
|
||||
// 声明立即生效(反代表在下一次请求时重建)。**不做去重**:同一 Host/Path
|
||||
// 被两条声明占用时由反代层判定冲突并明确报错,而不是在这里静默吞掉 ——
|
||||
// 插件作者需要看见冲突。
|
||||
func (s *PluginSDK) DeclareProxy(decl ProxyDecl) {
|
||||
//
|
||||
// 与 plugin.json 的 proxies 字段等价:写哪个都行,两者会合并(同名以本调用为准)。
|
||||
func (s *PluginSDK) RegisterProxy(name string, def ProxyDef) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.apiMu.Lock()
|
||||
d := s.proxyDecl
|
||||
s.apiMu.Unlock()
|
||||
if d != nil {
|
||||
d(decl)
|
||||
s.apiMu.RLock()
|
||||
r := s.proxyReg
|
||||
s.apiMu.RUnlock()
|
||||
if r != nil {
|
||||
r(name, def)
|
||||
}
|
||||
}
|
||||
|
||||
72
third_party/homeagent-sdk/sdk/proxy_test.go
vendored
72
third_party/homeagent-sdk/sdk/proxy_test.go
vendored
@ -1,6 +1,10 @@
|
||||
package sdk
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProxyAuthDefaultsToHomeAgent(t *testing.T) {
|
||||
// 空串必须归一化为「HomeAgent 统一保护」——这是安全默认。
|
||||
@ -76,8 +80,8 @@ func TestNormalizeProxyHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProxyDecl(t *testing.T) {
|
||||
valid := []ProxyDecl{
|
||||
func TestValidateProxyDef(t *testing.T) {
|
||||
valid := []ProxyDef{
|
||||
{Target: "127.0.0.1:12100"},
|
||||
{Target: "http://127.0.0.1:12100"},
|
||||
{Target: "127.0.0.1:12100", Host: "huawei"},
|
||||
@ -87,25 +91,73 @@ func TestValidateProxyDecl(t *testing.T) {
|
||||
{Target: "https://example.com", Host: "ext"}, // 远程上游也允许(由 auth 决定安全性)
|
||||
}
|
||||
for _, d := range valid {
|
||||
if msg := ValidateProxyDecl(d); msg != "" {
|
||||
if msg := ValidateProxyDef(d); msg != "" {
|
||||
t.Errorf("%+v 应合法,却报: %s", d, msg)
|
||||
}
|
||||
}
|
||||
|
||||
bad := []ProxyDecl{
|
||||
{}, // 无 target
|
||||
{Target: " "}, // 空白 target
|
||||
bad := []ProxyDef{
|
||||
{}, // 无 target
|
||||
{Target: " "}, // 空白 target
|
||||
{Target: "127.0.0.1:12100", Auth: "yes"}, // auth 非法
|
||||
{Target: "127.0.0.1:12100", Host: "a_b"}, // host 非法
|
||||
{Target: "127.0.0.1:12100", Host: "-x"},
|
||||
{Target: "127.0.0.1:12100", Host: "X"},
|
||||
{Target: "://12100"}, // 无主机
|
||||
{Target: "http:///path"}, // 无主机
|
||||
{Target: "://12100"}, // 无主机
|
||||
{Target: "http:///path"}, // 无主机
|
||||
{Target: "127.0.0.1:notaport"}, // 端口非数字
|
||||
}
|
||||
for _, d := range bad {
|
||||
if msg := ValidateProxyDecl(d); msg == "" {
|
||||
if msg := ValidateProxyDef(d); msg == "" {
|
||||
t.Errorf("%+v 应被拒绝,却通过了", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 单一入口原则 ----
|
||||
|
||||
// 被反代的插件必须能同时适配 Host 形态与 Path 形态。这两条判据把
|
||||
// 「插件内部不得用根绝对路径」这条契约钉在**可执行**的层面:
|
||||
// 声明合法不代表它的资源能被两种形态访问到 —— 后者取决于插件前端的写法,
|
||||
// 而 SDK 只能把要求写清楚并给出校验工具。
|
||||
func TestSingleEntryPrincipleDocumented(t *testing.T) {
|
||||
// Path 形态下插件前端必须用相对路径,否则请求会打到门户自己。
|
||||
// 这是**文档级约定**,只能靠 review 与这份判据共同保证:
|
||||
// 判据确保 SDK 里确实写明了这条要求(防止后来者删掉注释)。
|
||||
src, err := os.ReadFile("proxy.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"单一入口原则",
|
||||
"相对路径",
|
||||
"根绝对路径",
|
||||
} {
|
||||
if !strings.Contains(string(src), want) {
|
||||
t.Errorf("SDK 文档缺少「%s」—— 单一入口原则是反代的硬要求,不能只存在于口头约定里", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// strip_path 的两种语义必须由声明者显式选,且非法组合要被挡住。
|
||||
func TestStripPathValidation(t *testing.T) {
|
||||
// 合法:两种模式
|
||||
for _, d := range []ProxyDef{
|
||||
{Target: "127.0.0.1:1", Path: "/p/app", StripPath: true},
|
||||
{Target: "127.0.0.1:1", Path: "/api/v1/device", StripPath: false},
|
||||
} {
|
||||
if msg := ValidateProxyDef(d); msg != "" {
|
||||
t.Errorf("应合法却被拒: %+v → %s", d, msg)
|
||||
}
|
||||
}
|
||||
// 非法:strip_path 但没有 path(没有可剥的前缀)
|
||||
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", StripPath: true}); msg == "" {
|
||||
t.Error("strip_path=true 而无 path 应被拒(没有可剥的前缀)")
|
||||
}
|
||||
// 非法:前缀模式挂到根会吞掉整个门户。
|
||||
// 实际由「不应以 / 结尾」规则挡下("/" 同时是前缀又以 / 结尾),
|
||||
// 这里断言的是**行为**:这种声明无论如何都不能通过。
|
||||
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", Path: "/", StripPath: true}); msg == "" {
|
||||
t.Error("path=\"/\" + strip_path 应被拒(会覆盖整个门户)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user