From 5e8886578430c9be6184b36e8dce4fdfeb82a51c Mon Sep 17 00:00:00 2001
From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com>
Date: Fri, 25 Sep 2026 12:38:17 +0800
Subject: [PATCH] =?UTF-8?q?fix(webui):=20=E5=8F=8D=E4=BB=A3=E4=B8=A4?=
=?UTF-8?q?=E5=A4=84=E7=9C=9F=E5=AE=9E=E6=95=85=E9=9A=9C=20=E2=80=94?=
=?UTF-8?q?=E2=80=94=20=E5=87=AD=E8=AF=81=E5=A4=B4=E6=8C=89=20auth=20?=
=?UTF-8?q?=E5=8C=BA=E5=88=86=20+=20Host=20=E5=88=86=E5=8F=91=E5=85=88?=
=?UTF-8?q?=E4=BA=8E=E9=97=A8=E6=88=B7=E8=B7=AF=E7=94=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
两处都是**隔离实例上跑真实端到端**才暴露的,单测(用不校验凭证的假上游、
直接调 serveProxyHost)全绿却线上出错。记录在此以免重蹈。
## 故障 1:auth=none 路由的凭证被无条件剥掉 ⇒ 设备链路全 401
Rewrite 里原本无条件 Del("Cookie"/"Authorization"/"X-API-Key")。但
auth=none 的语义是"请求原样交给上游",凭证本来就是给**上游**的——
remotedevice 的接入令牌正是走 X-API-Key 传的。
实测症状:带设备令牌经反代访问 /api/v1/device/online → 401;
直连 127.0.0.1:9890 → 200。差异极难定位,因为两侧状态码语义相同。
修法:按路由 auth 分流。
- auth=homeagent:凭证是门户的,剥掉(避免泄漏给插件)
- auth=none:保留(上游要用)
复验:经反代与直连**逐字节一致**(HTTP 200 / 17B,cmp 相同)。
## 故障 2:插件子域被门户路由截走 ⇒ 401 且响应体是门户的 JSON
原实现把 Host 分发放在 mux 的 "/" 兜底里。但 stdlib ServeMux 是**最长前缀
优先**:任何更具体的模式都先命中。插件子域上的 /api/v1/device/online 被门户
为「旧路径反代」注册的 /api/v1/device/ 接走(requireAPI 包裹)→ 401。
判据(响应体格式)是定位关键:
webui requireAPI → {"error":"unauthorized"} 25B ← 实际拿到
remotedevice requireToken → "unauthorized" text/plain 13B
看响应体格式就能区分是谁拒的,比看状态码有效。
修法:Host 分发提为**最外层中间件**,包在整个 mux 之外,先于任何路径匹配。
## 顺带:消除判据与生产接线错位的可能
新增 Handler.Handler() 返回生产用的完整链(Host 分发 → 日志 → mux),
plugin.go 与测试共用同一条。本次踩过:测试自己组装 mux、中间件却挂在
plugin.go,判据全绿而线上 401;共享同一条链可结构性避免。
(写这条判据时还发现测试里 h.mux 为 nil 导致 panic——也正是这种错位的表现。)
## 新增判据 2 条
- TestProxyCredentialHeadersDependOnAuth:auth=none 必须转发上游令牌、
auth=homeagent 必须剥掉门户凭证(两个方向都钉)
- TestProxyHostTakesPrecedenceOverPortalRoutes:走**完整生产链**,确认
插件子域上的 /api/v1/device/online、/api/v1/status、/api/v1/plugins/ 都
归反代;同时确认门户自身的 /api/v1/status 仍返回门户 JSON(没被反代吞掉)
## 真实验收(隔离实例:命名 netns + 独立 data + 端口 18080)
- 设备网关:令牌经反代 200,与直连逐字节一致;WS 升级 101
- 未声明子域:404 且错误信息含具体标签
- huawei_smarthome(用新 hmapdev 重打包、真装载):
匿名 401 + 可操作提示;带门户 key 拿到真实 UI(9444B,
华为智慧生活管家);页面内根绝对路径 /api/status 正确透传
---
internal/plugins/webui/handler.go | 45 ++++++---
internal/plugins/webui/plugin.go | 2 +-
internal/plugins/webui/proxy.go | 24 +++--
internal/plugins/webui/proxy_test.go | 141 +++++++++++++++++++++++++++
4 files changed, 194 insertions(+), 18 deletions(-)
diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go
index dd568385..217e7968 100644
--- a/internal/plugins/webui/handler.go
+++ b/internal/plugins/webui/handler.go
@@ -102,6 +102,9 @@ type Handler struct {
// hostPort 是 webui 实际监听的 ":port"(用于推导服务入口链接)。
hostPort string
+ // mux 由 RegisterRoutes 记下,供 Handler() 组装生产链。
+ mux *http.ServeMux
+
sessionMu sync.Mutex
sessions map[string]time.Time
@@ -398,7 +401,37 @@ func (sw *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return nil, nil, fmt.Errorf("hijack not supported")
}
+// proxyDispatch 是**最外层**的 Host 分发中间件。
+//
+// 为什么不放在 mux 的 "/" 兜底里(这是实测踩出来的):stdlib ServeMux 是
+// 最长前缀优先,任何更具体的模式都会先命中。插件子域上的路径若与门户某条
+// 路由同名(/api/v1/device/online 就是——门户为「旧路径反代」注册了
+// /api/v1/device/),请求会被那条**面向门户**的路由截走:实测表现为 401,
+// 且响应体是门户的 {"error":"unauthorized"} 而不是上游的响应,极难排查。
+//
+// 包在 mux 外层后,Host 判定先于任何路径匹配发生:插件子域整体交给反代,
+// 主门户 Host 则原样下沉给 mux 走各自路由,两边互不干扰。
+// Handler 返回**生产用的完整处理链**(外 → 内:Host 分发 → 日志 → mux)。
+//
+// 抽成一个方法而非在 plugin.go 里手写组合:测试必须能拿到与线上**逐字节
+// 相同**的链,否则很容易测出错位的东西——本次就踩过:测 mux 而中间件挂在
+// plugin.go,判据全绿却在真实实例上 401。共享同一条链可以结构性地避免
+// 这类漂移。
+func (h *Handler) Handler() http.Handler {
+ return h.proxyDispatch(h.logged(h.mux))
+}
+
+func (h *Handler) proxyDispatch(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if h.serveProxyHost(w, r) {
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
+ h.mux = mux
mux.HandleFunc("/login", h.handleLoginPage)
mux.HandleFunc("/api/v1/login", h.handleLogin)
mux.HandleFunc("/api/v1/logout", h.handleLogout)
@@ -447,17 +480,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// 走 requireAPI:清单本身含上游地址,属于管理面信息,不该匿名可读。
mux.HandleFunc("/api/v1/proxy/services", h.requireAPI(h.handleProxyServices))
mux.HandleFunc("/api/v1/proxy", h.requireAPI(h.handleProxyInfo))
- // "/" 兜底:**先**尝试按 Host 分发到插件反代,不是插件子域才落到主站静态页。
- // 顺序很重要——ServeMux 只会把未被更具体模式匹配的请求交给这里。
- mux.HandleFunc("/", h.handleRoot)
-}
-
-// handleRoot 是根路由兜底:先看是不是插件反代的子域,不是再走主站。
-func (h *Handler) handleRoot(w http.ResponseWriter, r *http.Request) {
- if h.serveProxyHost(w, r) {
- return
- }
- h.requireWeb(h.handleStatic)(w, r)
+ mux.HandleFunc("/", h.requireWeb(h.handleStatic))
}
func (h *Handler) handleLoginPage(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go
index 229c21bc..5e663a09 100644
--- a/internal/plugins/webui/plugin.go
+++ b/internal/plugins/webui/plugin.go
@@ -308,7 +308,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
if _, port, err := net.SplitHostPort(ln.Addr().String()); err == nil {
p.handler.hostPort = ":" + port
}
- p.server = &http.Server{Handler: p.handler.logged(p.mux)}
+ p.server = &http.Server{Handler: p.handler.Handler()}
go func() {
if err := p.server.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Printf("[webui] server error: %v", err)
diff --git a/internal/plugins/webui/proxy.go b/internal/plugins/webui/proxy.go
index b8cb8381..441daa5e 100644
--- a/internal/plugins/webui/proxy.go
+++ b/internal/plugins/webui/proxy.go
@@ -227,7 +227,7 @@ func buildProxyTable(decls []proxyDecl, manualText string, settings sdk.Settings
continue
}
r.upstream = u
- r.reverse = newReverseProxy(u)
+ r.reverse = newReverseProxy(u, r.Auth)
log.Printf("[webui] 反代: %s.%s → %s (plugin=%s ws=%v auth=%s)",
r.Host, base, r.Target, r.Plugin, r.WS, r.Auth)
}
@@ -276,17 +276,29 @@ func parseUpstream(target string) (*url.URL, error) {
// 泄给客户端。ReverseProxy 默认不跟随重定向,3xx 原样透传。
// 3. **补齐转发头**:SetXForwarded 注入 X-Forwarded-For/Host/Proto,
// 旧实现完全不注入,上游无法判断真实来源。
-func newReverseProxy(u *url.URL) *httputil.ReverseProxy {
+func newReverseProxy(u *url.URL, auth string) *httputil.ReverseProxy {
+ stripCredentials := auth == sdk.ProxyAuthHomeAgent
rp := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(u)
pr.SetXForwarded()
// 透传子域标签给上游(插件据此可感知自己被挂在哪个标签下)。
pr.Out.Header.Set("X-HA-Proxy-Host", pr.In.Host)
- // 上游可能自带鉴权,浏览器带来的门户 cookie 不应泄漏给它。
- pr.Out.Header.Del("Cookie")
- pr.Out.Header.Del("Authorization")
- pr.Out.Header.Del("X-API-Key")
+ // 凭证头的处理**必须按路由的 auth 分开**:
+ //
+ // auth=homeagent:凭证是给门户的(会话 cookie / 门户 API Key),
+ // 上游不需要也不该看到它们 ⇒ 剥掉,避免把门户凭证泄漏给插件。
+ // auth=none:请求就是要原样交给上游的,凭证本来就是给**上游**的
+ // (设备网关的接入令牌正是通过 X-API-Key 传的)⇒ 必须保留。
+ //
+ // 这里踩过一次真实故障:无条件剥 X-API-Key 导致 auth=none 的设备
+ // 链路全部 401(直连 9890 是 200,经反代却 401)。单测用的是不校验
+ // 凭证的假上游,抓不到;是**真实端到端**(真设备网关 + 真令牌)发现的。
+ if stripCredentials {
+ pr.Out.Header.Del("Cookie")
+ pr.Out.Header.Del("Authorization")
+ pr.Out.Header.Del("X-API-Key")
+ }
},
FlushInterval: -1, // 立即 flush:SSE/长轮询逐帧下发
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
diff --git a/internal/plugins/webui/proxy_test.go b/internal/plugins/webui/proxy_test.go
index 9182274e..9bfd0a7b 100644
--- a/internal/plugins/webui/proxy_test.go
+++ b/internal/plugins/webui/proxy_test.go
@@ -713,3 +713,144 @@ func TestAutoDiscoveryRoutesEndToEnd(t *testing.T) {
t.Fatalf("自动发现的声明未生效: code=%d body=%q", rec.Code, rec.Body.String())
}
}
+
+// ---- 凭证头按 auth 区分:真实端到端发现过这个 bug ----
+//
+// auth=none 的路由,凭证是给**上游**的(设备网关的接入令牌走 X-API-Key),
+// 必须原样转发;无条件剥掉会让设备链路全部 401。
+// auth=homeagent 的路由,凭证是给门户的,绝不能泄漏给上游。
+//
+// 说明:这条判据是**事后补的**。此前的单测用「不校验凭证的假上游」,
+// 抓不到这个 bug;是隔离实例上跑真实 remotedevice(真令牌、真校验)
+// 才发现「直连 200、经反代 401」。
+func TestProxyCredentialHeadersDependOnAuth(t *testing.T) {
+ type probe struct {
+ cookie string
+ apiKey string
+ bearer string
+ }
+ got := make(chan probe, 4)
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got <- probe{
+ cookie: r.Header.Get("Cookie"),
+ apiKey: r.Header.Get("X-API-Key"),
+ bearer: r.Header.Get("Authorization"),
+ }
+ w.Write([]byte("ok"))
+ }))
+ defer up.Close()
+
+ prev := declProvider
+ SetProxyDeclProvider(func() []proxyDecl {
+ return []proxyDecl{
+ {Plugin: "dev", Name: "gw", Host: "dev", Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
+ {Plugin: "ui", Name: "ui", Host: "ui", Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthHomeAgent},
+ }
+ })
+ manualProxyRoutes = ""
+ InvalidateProxyRoutes()
+ t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
+
+ h := NewHandler(proxyTestSettings(t))
+ call := func(host, apiKey string) probe {
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/", nil)
+ r.Host = host
+ // 同时带上三样:受保护路由要靠门户 key 过鉴权,而我们要断言的是
+ // **这三样都不该到上游**;不受保护路由则用设备令牌,且必须到上游。
+ r.Header.Set("X-API-Key", apiKey)
+ r.Header.Set("Authorization", "Bearer UPSTREAM-BEARER")
+ r.Header.Set("Cookie", "homeagent_session=PORTAL")
+ h.serveProxyHost(rec, r)
+ select {
+ case p := <-got:
+ return p
+ case <-time.After(3 * time.Second):
+ t.Fatalf("%s 未到达上游", host)
+ return probe{}
+ }
+ }
+
+ // auth=none:上游自己的令牌必须保留
+ // auth=none:用的就是设备令牌(它同时也是发给上游的凭证)
+ p := call("dev.localhost:8080", "UPSTREAM-DEVICE-TOKEN")
+ if p.apiKey != "UPSTREAM-DEVICE-TOKEN" {
+ t.Errorf("auth=none 时 X-API-Key 必须转发给上游(设备令牌),实际 %q", p.apiKey)
+ }
+ if p.bearer != "Bearer UPSTREAM-BEARER" {
+ t.Errorf("auth=none 时 Authorization 应转发,实际 %q", p.bearer)
+ }
+
+ // auth=homeagent:门户凭证绝不能泄漏
+ // auth=homeagent:用门户 key 通过鉴权,再看它有没有被转发出去
+ q := call("ui.localhost:8080", "test-api-key")
+ if q.apiKey != "" {
+ t.Errorf("auth=homeagent 时 X-API-Key(门户密钥)泄漏给上游: %q", q.apiKey)
+ }
+ if strings.Contains(q.cookie, "PORTAL") {
+ t.Errorf("auth=homeagent 时门户 cookie 泄漏给上游: %q", q.cookie)
+ }
+ if q.bearer != "" {
+ t.Errorf("auth=homeagent 时 Authorization 泄漏给上游: %q", q.bearer)
+ }
+}
+
+// ---- Host 分发必须先于门户路由判定 ----
+//
+// 真实端到端踩到的坑:插件子域上的路径若与门户某条更具体的路由同名
+// (例如 /api/v1/device/online),会被那条**面向门户**的路由截走——
+// 表现为 401,且响应体是门户的 JSON 而非上游的响应。
+//
+// 本判据经由完整 RegisterRoutes(而不是直接调 serveProxyHost)验证:
+// 走一遍真实 mux,确认插件子域被 Host 分发接住。
+func TestProxyHostTakesPrecedenceOverPortalRoutes(t *testing.T) {
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ w.Write([]byte("UPSTREAM-ANSWER"))
+ }))
+ defer up.Close()
+
+ prev := declProvider
+ SetProxyDeclProvider(func() []proxyDecl {
+ return []proxyDecl{{
+ Plugin: "dev", Name: "gw", Host: "dev",
+ Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone,
+ }}
+ })
+ manualProxyRoutes = ""
+ InvalidateProxyRoutes()
+ t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
+
+ h := NewHandler(proxyTestSettings(t))
+ h.RegisterRoutes(http.NewServeMux())
+
+ // 路径刻意选一个门户也注册了更具体模式的路径。
+ // 若 Host 分发没生效,会被 /api/v1/device/ 的 requireAPI 接走 → 401 JSON。
+ for _, p := range []string{"/api/v1/device/online", "/api/v1/status", "/api/v1/plugins/", "/"} {
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", p, nil)
+ r.Host = "dev.localhost:8080"
+ h.Handler().ServeHTTP(rec, r)
+ if rec.Code != 200 || rec.Body.String() != "UPSTREAM-ANSWER" {
+ t.Errorf("插件子域 %s 被门户路由截走了:code=%d body=%q(应为上游响应)",
+ p, rec.Code, rec.Body.String())
+ }
+ }
+
+ // 主门户 Host 上,同样的路径必须仍走门户自己的路由(不能被反代吞掉)
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/api/v1/status", nil)
+ r.Host = "localhost:8080"
+ r.Header.Set("X-API-Key", "test-api-key")
+ h.Handler().ServeHTTP(rec, r)
+ if rec.Code != 200 {
+ t.Fatalf("门户自身的 /api/v1/status 不可用:%d", rec.Code)
+ }
+ var st map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil {
+ t.Fatalf("门户 /api/v1/status 返回的不是门户 JSON(被反代吞了?): %s", rec.Body.String())
+ }
+ if st["status"] != "running" {
+ t.Errorf("门户 /api/v1/status 返回异常: %v", st)
+ }
+}