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

@ -50,14 +50,15 @@ import (
// ProxyRoute 是一条**已解析**的反代路由(声明 + 归属插件 + 校验结果)。
type ProxyRoute struct {
Plugin string // 声明该服务的插件名
Name string // 声明内的服务标识(展示用,如 "ui")
Host string // 子域标签(小写,已归一化)
Path string // 可选的路径挂载前缀(非浏览器客户端用,无 DNS 依赖)
Target string // 上游地址(原样,含可能的 scheme/路径前缀)
WS bool // 是否允许 WebSocket 升级
Auth string // 生效的鉴权模式(已归一化)
Err string // 非空表示该条声明被拒绝及原因(不参与路由,仅展示)
Plugin string // 声明该服务的插件名
Name string // 声明内的服务标识(展示用,如 "ui")
Host string // 子域标签(小写,已归一化)
Path string // 可选的路径挂载前缀(非浏览器客户端用,无 DNS 依赖)
StripPath bool // 转发前是否剥掉 Path 前缀(见 SDK 的说明:两种语义真实不同)
Target string // 上游地址(原样,含可能的 scheme/路径前缀)
WS bool // 是否允许 WebSocket 升级
Auth string // 生效的鉴权模式(已归一化)
Err string // 非空表示该条声明被拒绝及原因(不参与路由,仅展示)
upstream *url.URL
reverse *httputil.ReverseProxy
@ -191,16 +192,17 @@ func buildProxyTable(decls []proxyDecl, manualText string, settings sdk.Settings
for _, d := range decls {
r := &ProxyRoute{
Plugin: d.Plugin,
Name: d.Name,
Host: d.Host,
Path: strings.TrimSpace(d.Path),
Target: d.Target,
WS: d.WebSocket,
Auth: sdk.EffectiveProxyAuth(d.Auth),
Plugin: d.Plugin,
Name: d.Name,
Host: d.Host,
Path: strings.TrimSpace(d.Path),
StripPath: d.StripPath,
Target: d.Target,
WS: d.WebSocket,
Auth: sdk.EffectiveProxyAuth(d.Auth),
}
if msg := sdk.ValidateProxyDecl(sdk.ProxyDecl{
Name: d.Name, Host: d.Host, Path: d.Path,
if msg := sdk.ValidateProxyDef(sdk.ProxyDef{
Host: d.Host, Path: d.Path, StripPath: d.StripPath,
Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth,
}); msg != "" {
r.Err = msg
@ -212,16 +214,17 @@ func buildProxyTable(decls []proxyDecl, manualText string, settings sdk.Settings
for _, d := range parseManualRoutes(manualText) {
r := &ProxyRoute{
Plugin: "manual",
Name: d.Name,
Host: d.Host,
Path: strings.TrimSpace(d.Path),
Target: d.Target,
WS: d.WebSocket,
Auth: sdk.EffectiveProxyAuth(d.Auth),
Plugin: "manual",
Name: d.Name,
Host: d.Host,
Path: strings.TrimSpace(d.Path),
StripPath: d.StripPath,
Target: d.Target,
WS: d.WebSocket,
Auth: sdk.EffectiveProxyAuth(d.Auth),
}
if msg := sdk.ValidateProxyDecl(sdk.ProxyDecl{
Name: d.Name, Host: d.Host, Path: d.Path,
if msg := sdk.ValidateProxyDef(sdk.ProxyDef{
Host: d.Host, Path: d.Path, StripPath: d.StripPath,
Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth,
}); msg != "" {
r.Err = msg
@ -241,7 +244,13 @@ func buildProxyTable(decls []proxyDecl, manualText string, settings sdk.Settings
continue
}
r.upstream = u
r.reverse = newReverseProxy(u, r.Auth)
// 前缀模式:Rewrite 时把 Path 前缀剥掉再交给上游。
// 别名模式(原样保留):不需要额外处理 —— 客户端用的就是上游的真实路径。
prefix := ""
if r.StripPath {
prefix = r.Path
}
r.reverse = newReverseProxy(u, r.Auth, prefix)
log.Printf("[webui] 反代: %s.%s → %s (plugin=%s ws=%v auth=%s)",
r.Host, base, r.Target, r.Plugin, r.WS, r.Auth)
}
@ -290,11 +299,28 @@ func parseUpstream(target string) (*url.URL, error) {
// 泄给客户端。ReverseProxy 默认不跟随重定向,3xx 原样透传。
// 3. **补齐转发头**:SetXForwarded 注入 X-Forwarded-For/Host/Proto,
// 旧实现完全不注入,上游无法判断真实来源。
func newReverseProxy(u *url.URL, auth string) *httputil.ReverseProxy {
func newReverseProxy(u *url.URL, auth, stripPrefix string) *httputil.ReverseProxy {
stripCredentials := auth == sdk.ProxyAuthHomeAgent
rp := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(u)
// 前缀模式:剥掉门户上的挂载前缀,上游看到它自己的路径。
// 别名模式(stripPrefix == ""):路径原样,见 SDK 的 Path 说明。
if stripPrefix != "" {
// 路径恰好等于前缀时(/p/myapp)余量是空串 —— 那是上游的
// **根**,必须映射成 "/" 而不是保持原样(保持原样会把
// /p/myapp 整个当路径转给上游,上游无从识别)。
rest := strings.TrimPrefix(pr.In.URL.Path, stripPrefix)
if rest == "" {
rest = "/"
}
pr.Out.URL.Path = rest
pr.Out.URL.RawPath = ""
// SetURL 已按上游 base path 拼过一次,这里以 rest 为准重设。
if u.Path != "" && u.Path != "/" {
pr.Out.URL.Path = strings.TrimSuffix(u.Path, "/") + rest
}
}
pr.SetXForwarded()
// 透传子域标签给上游(插件据此可感知自己被挂在哪个标签下)。
pr.Out.Header.Set("X-HA-Proxy-Host", pr.In.Host)
@ -376,7 +402,7 @@ func currentProxyTable() *proxyTable {
decls = declProvider()
}
// 内置插件的运行期声明(无 plugin.json,扫目录发现不到)。
for plugin, list := range sdk.BuiltinProxyDecls() {
for plugin, list := range sdk.BuiltinProxyDefs() {
for _, d := range list {
host := d.Host
if host == "" {
@ -507,6 +533,8 @@ func hostnameOf(hostport string) string {
func (t *proxyTable) matchProxyPath(p string) (*ProxyRoute, bool) {
var best *ProxyRoute
for prefix, r := range t.paths {
// 边界必须卡在路径分隔符上:/p/huawei 不能匹配 /p/huaweix
// (否则会劫持同前缀的其它管理页),但 /p/huawei 自身与其子路径都算。
if p == prefix || strings.HasPrefix(p, prefix+"/") {
if best == nil || len(prefix) > len(best.Path) {
best = r
@ -541,6 +569,20 @@ func (h *Handler) serveProxyHost(w http.ResponseWriter, r *http.Request) bool {
// *.localhost)也能用:它们连门户地址本身即可,不需要知道反代的存在。
// 路径原样保留 —— 客户端沿用它已有的路径。
if rt, ok := t.matchProxyPath(r.URL.Path); ok && proxyHostLabel(r.Host, base) == "" {
// 前缀模式下,路径**恰好等于挂载前缀**(/p/huawei,无尾斜杠)时
// 必须重定向到 /p/huawei/。
//
// 原因在前端:相对路径的基准是「当前文档目录」。地址是 /p/huawei 时
// 浏览器算出的目录是 /p/ —— 页面里的 fetch('api/status') 会打到
// /p/api/status(404),看起来像插件坏了。补上尾斜杠后目录成为
// /p/huawei/,相对路径立即正确。
//
// 别名模式(strip_path=false)**不能**这样做:那类客户端的路径是
// 上游真实路径(/api/v1/device/online),加尾斜杠会改变语义。
if rt.StripPath && rt.Path != "" && r.URL.Path == rt.Path {
h.redirectToTrailingSlash(w, r, rt.Path)
return true
}
if isWebSocketUpgrade(r) && !rt.WS {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("插件 %s 的服务 %s 未声明 websocket", rt.Plugin, rt.Name),
@ -628,12 +670,13 @@ func isWebSocketUpgrade(r *http.Request) bool {
// proxyServiceEntry 是「服务入口」条目:给前端渲染选项卡用。
type proxyServiceEntry struct {
Plugin string `json:"plugin"`
PluginZh string `json:"plugin_name"`
Name string `json:"name"`
Host string `json:"host"`
Path string `json:"path,omitempty"` // 路径挂载前缀(无 DNS 依赖的形态)
URL string `json:"url"` // 子域形态(浏览器)
Plugin string `json:"plugin"`
PluginZh string `json:"plugin_name"`
Name string `json:"name"`
Host string `json:"host"`
Path string `json:"path,omitempty"` // 路径挂载前缀(无 DNS 依赖的形态)
StripPath bool `json:"strip_path,omitempty"` // 该前缀是否被剥掉后转发
URL string `json:"url"` // 子域形态(浏览器)
// URLPortal 是门户同源形态:挂在门户自身 host 的路径下,**无 DNS 依赖**。
// 非浏览器客户端(设备/固件/CLI)用系统解析器解析不了 *.localhost,用它。
URLPortal string `json:"url_portal,omitempty"`
@ -663,15 +706,16 @@ func (h *Handler) listProxyServices(scheme, hostPort, portalHost, domain string)
out := make([]proxyServiceEntry, 0, len(t.ordered))
for _, r := range t.ordered {
e := proxyServiceEntry{
Plugin: r.Plugin,
Name: r.Name,
Host: r.Host,
Path: r.Path,
Auth: r.Auth,
WS: r.WS,
Target: r.Target,
OK: r.Err == "",
Error: r.Err,
Plugin: r.Plugin,
Name: r.Name,
Host: r.Host,
Path: r.Path,
StripPath: r.StripPath,
Auth: r.Auth,
WS: r.WS,
Target: r.Target,
OK: r.Err == "",
Error: r.Err,
}
if m, ok := metas[r.Plugin]; ok {
e.PluginZh = m.NameZh
@ -704,6 +748,7 @@ type proxyDecl struct {
Name string
Host string
Path string
StripPath bool
Target string
WebSocket bool
Auth string
@ -753,6 +798,7 @@ func readPluginProxyDecls(pluginDir string) []proxyDecl {
Name: sname,
Host: strings.ToLower(host),
Path: strings.TrimSpace(p.Path),
StripPath: p.StripPath,
Target: p.Target,
WebSocket: p.WebSocket,
Auth: p.Auth,
@ -1034,3 +1080,21 @@ func (h *Handler) handleDeviceGatewayDiscovery(w http.ResponseWriter, r *http.Re
out["target"] = route.Target
writeJSON(w, http.StatusOK, out)
}
// redirectToTrailingSlash 把 /p/app 重定向到 /p/app/(保留查询串)。
//
// 为什么需要:相对路径的解析基准是「当前文档所在目录」。没有尾斜杠时
// 浏览器把最后一段当**文件名**,目录退回上一级 —— 页面里的
// fetch('api/status') 于是打到 /p/api/status 而不是 /p/app/api/status。
// 表现为:页面能打开,但所有数据加载失败(很容易误判成插件故障)。
//
// 用 301 而不是 302:这是稳定的规范形态,浏览器与中间层都可以长期缓存。
func (h *Handler) redirectToTrailingSlash(w http.ResponseWriter, r *http.Request, prefix string) {
target := prefix + "/"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
// 保留门户可能挂载的额外前缀(如外层网关又套了一层 /ha)。
// 这里以请求的真实路径为准做相对拼装,避免绝对路径丢失上下文。
http.Redirect(w, r, target, http.StatusMovedPermanently)
}

View File

@ -1442,3 +1442,202 @@ func TestBaseURLTolerant(t *testing.T) {
}
}
}
// ---- 路径挂载的两种语义(必须由声明者选,不能猜)----
//
// 别名模式(strip_path=false,默认):Path 是上游真实路径的一部分。
//
// 设备网关就是这种 —— 客户端硬编码 /api/v1/device/ws,不可能知道反代。
//
// 前缀模式(strip_path=true):Path 只是门户上的挂载点,上游不知道它。
//
// 自带 UI 的服务是这种 —— 前端用相对路径,被挂到哪里都对。
//
// 猜错的结果是全部请求 404,且看起来像上游故障,所以必须显式声明。
func TestProxyPathAliasVsStrip(t *testing.T) {
var gotPath string
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Write([]byte("ok"))
}))
defer up.Close()
prev := declProvider
SetProxyDeclProvider(func() []proxyDecl {
return []proxyDecl{
// 别名:原样保留(机器接口,客户端已硬编码路径)
{Plugin: "gw", Name: "gateway", Host: "gw", Path: "/api/v1/device",
Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
// 前缀:剥掉后转发(自带 UI 的服务)
{Plugin: "ui", Name: "ui", Host: "ui", Path: "/p/myapp", StripPath: true,
Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
}
})
manualProxyRoutes = ""
InvalidateProxyRoutes()
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
h := NewHandler(nil)
call := func(p string) string {
gotPath = ""
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", p, nil)
r.Host = "127.0.0.1:8080"
if !h.serveProxyHost(rec, r) {
t.Fatalf("%s 未被路径挂载接住", p)
}
return gotPath
}
// 别名模式:上游必须收到**一模一样**的路径
if p := call("/api/v1/device/ws"); p != "/api/v1/device/ws" {
t.Errorf("别名模式:上游收到 %q,期望原样 /api/v1/device/ws", p)
}
if p := call("/api/v1/device/online"); p != "/api/v1/device/online" {
t.Errorf("别名模式:上游收到 %q", p)
}
// 前缀模式:上游必须收到**剥掉前缀之后**的路径
if p := call("/p/myapp/api/status"); p != "/api/status" {
t.Errorf("前缀模式:上游收到 %q,期望 /api/status(前缀应被剥掉)", p)
}
if p := call("/p/myapp/"); p != "/" {
t.Errorf("前缀模式根:上游收到 %q,期望 /", p)
}
// 无尾斜杠时**不再转发**,而是 301 到带尾斜杠的形态 ——
// 否则浏览器算出的相对路径基准会退回上一级(见
// TestProxyStripPathRedirectsToTrailingSlash)。这里断言它确实
// 没有把 /p/myapp 当路径转给上游。
gotPath = ""
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/p/myapp", nil)
r.Host = "127.0.0.1:8080"
h.serveProxyHost(rec, r)
if rec.Code != http.StatusMovedPermanently || gotPath != "" {
t.Errorf("前缀模式无尾斜杠应 301 且不转发,实际 code=%d upstream_path=%q", rec.Code, gotPath)
}
}
// 路径前缀匹配必须**最长优先**,且边界卡在分隔符上。
func TestProxyPathLongestPrefixWins(t *testing.T) {
var gotPath string
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Write([]byte("ok"))
}))
defer up.Close()
prev := declProvider
SetProxyDeclProvider(func() []proxyDecl {
return []proxyDecl{
{Plugin: "a", Name: "short", Host: "a", Path: "/p/app", StripPath: true,
Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
{Plugin: "b", Name: "long", Host: "b", Path: "/p/app/admin", StripPath: true,
Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
}
})
manualProxyRoutes = ""
InvalidateProxyRoutes()
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
h := NewHandler(nil)
call := func(p string) string {
gotPath = ""
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", p, nil)
r.Host = "127.0.0.1:8080"
h.serveProxyHost(rec, r)
return gotPath
}
// 更长前缀必须胜出(否则 /p/app/admin/x 会被 /p/app 抢走)
if p := call("/p/app/admin/x"); p != "/x" {
t.Errorf("最长前缀未生效:上游收到 %q,期望 /x(由 /p/app/admin 处理)", p)
}
if p := call("/p/app/other"); p != "/other" {
t.Errorf("短前缀处理: %q,期望 /other", p)
}
// 边界:/p/app 不得匹配 /p/apple(否则会劫持无关路径)
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/p/apple/pie", nil)
r.Host = "127.0.0.1:8080"
if h.serveProxyHost(rec, r) {
t.Errorf("/p/apple 被 /p/app 前缀劫持了(边界必须卡在路径分隔符): gotPath=%q", gotPath)
}
}
// ---- 前缀模式的尾斜杠(相对路径的基准)----
//
// 真实踩到的 bug:/p/huawei 能打开但页面里所有 fetch 都 404。
// 原因是相对路径以「当前文档目录」为基准 —— 没有尾斜杠时浏览器把最后
// 一段当文件名,目录退回上一级,fetch('api/status') 打到 /p/api/status。
// 表现为「页面能开、数据全空」,极易误判成插件故障。
func TestProxyStripPathRedirectsToTrailingSlash(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("upstream:" + r.URL.Path))
}))
defer up.Close()
prev := declProvider
SetProxyDeclProvider(func() []proxyDecl {
return []proxyDecl{
// 前缀模式:需要尾斜杠重定向
{Plugin: "ui", Name: "ui", Host: "ui", Path: "/p/app", StripPath: true,
Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
// 别名模式:绝不能重定向(路径是上游真实语义)
{Plugin: "gw", Name: "gw", Host: "gw", Path: "/api/v1/device",
Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
}
})
manualProxyRoutes = ""
InvalidateProxyRoutes()
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
h := NewHandler(nil)
do := func(p string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", p, nil)
r.Host = "127.0.0.1:8080"
if !h.serveProxyHost(rec, r) {
t.Fatalf("%s 未被接住", p)
}
return rec
}
// 前缀模式 + 无尾斜杠 → 必须 301 到带尾斜杠
rec := do("/p/app")
if rec.Code != http.StatusMovedPermanently {
t.Errorf("/p/app 应 301 到 /p/app/,实际 %d(body=%s)", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "/p/app/" {
t.Errorf("Location = %q,期望 /p/app/", loc)
}
// 查询串必须保留
rec = do("/p/app?a=1&b=2")
if loc := rec.Header().Get("Location"); loc != "/p/app/?a=1&b=2" {
t.Errorf("带查询串的 Location = %q,期望 /p/app/?a=1&b=2", loc)
}
// 有尾斜杠 → 正常转发到上游根
rec = do("/p/app/")
if rec.Code != http.StatusOK || rec.Body.String() != "upstream:/" {
t.Errorf("/p/app/ 应转发到上游 /,实际 %d %q", rec.Code, rec.Body.String())
}
// 子路径不受影响(不重定向)
rec = do("/p/app/api/status")
if rec.Code != http.StatusOK || rec.Body.String() != "upstream:/api/status" {
t.Errorf("/p/app/api/status 应转发到 /api/status,实际 %d %q", rec.Code, rec.Body.String())
}
// ★ 别名模式绝不能重定向:/api/v1/device 是上游真实路径,加斜杠会毁掉语义
rec = do("/api/v1/device")
if rec.Code == http.StatusMovedPermanently {
t.Errorf("别名模式的 /api/v1/device 被重定向了 —— 那类客户端的路径是上游真实语义,不能改")
}
if rec.Body.String() != "upstream:/api/v1/device" {
t.Errorf("别名模式应原样转发 /api/v1/device,实际 %q", rec.Body.String())
}
}