diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index dd56838..217e796 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 229c21b..5e663a0 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 b8cb838..441daa5 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 9182274..9bfd0a7 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) + } +}