From a96ad9ca970f383f1380fbc56b5767d0de1586e5 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 25 Sep 2026 15:27:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(webui):=20=E6=9C=8D=E5=8A=A1=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=20URL=20=E7=AB=AF=E5=8F=A3=E5=BF=85=E9=A1=BB=E6=81=B0?= =?UTF-8?q?=E5=A5=BD=E5=87=BA=E7=8E=B0=E4=B8=80=E6=AC=A1=EF=BC=88=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E9=83=A8=E7=BD=B2=E5=90=8E=E6=9A=B4=E9=9C=B2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产部署后立刻暴露的真 bug:请求 Host 自带端口(实测 Host=127.0.0.1:8080), 而 url_portal 合成时无条件再追加监听端口,拼出 http://127.0.0.1:8080:8080/api/v1/device/ ← 链接点不开 单测抓不到的原因:此前测试用的 Host 不含端口。真实服务器上 Host 一定带端口 (除非经 nginx 剥掉),所以这个 bug 必然出现在生产。 修法:抽出 portalHostWithPort(host, hostPort) 统一合成 —— - host 已含端口 → 原样(尊重调用方看到的真实入口) - host 不含端口 → 追加监听端口 两处调用点(服务清单 url_portal、发现端点 url_portal/url/http_url)共用它。 新增 3 条判据,都刻意用**自带端口**的 Host: TestPortalHostPortExactlyOnce(7 组输入,含带/不带端口、空值、无冒号端口) TestProxyServiceURLsWithPortInHost TestDeviceGatewayDiscoveryNoDuplicatePort --- internal/plugins/webui/proxy.go | 54 +++++++++---- internal/plugins/webui/proxy_test.go | 116 +++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 17 deletions(-) diff --git a/internal/plugins/webui/proxy.go b/internal/plugins/webui/proxy.go index 60f1cbe..2eb7945 100644 --- a/internal/plugins/webui/proxy.go +++ b/internal/plugins/webui/proxy.go @@ -399,6 +399,35 @@ func currentProxyTable() *proxyTable { return proxySnap } +// portalHostWithPort 把「主机」与「端口」合成恰好带一个端口的 host。 +// +// 必须做这一步:请求的 Host 头**可能已经带端口**(实测生产实例的 Host 是 +// 127.0.0.1:8080),此时再无脑追加 hostPort 就会得到 +// "127.0.0.1:8080:8080" —— 链接点不开,而且这个 bug 只在真实服务器上出现 +// (单测的 Host 通常不含端口,抓不到)。 +// +// 规则: +// - host 已含端口 → 原样返回(尊重调用方看到的真实入口); +// - host 不含端口 → 追加监听端口(否则 http 场景下链接缺端口); +// - 以下情况不追加端口:端口为空、或 host 已含端口。 +func portalHostWithPort(host, hostPort string) string { + h := strings.TrimSpace(host) + if h == "" { + h = "localhost" + } + if _, _, err := net.SplitHostPort(h); err == nil { + return h // 已经带端口 + } + p := strings.TrimSpace(hostPort) + if p == "" { + return h + } + if !strings.HasPrefix(p, ":") { + p = ":" + p + } + return h + p +} + // matchProxyPath 按**最长前缀**匹配路径挂载的服务。 // // 边界要卡在路径分隔符上:/api/v1/device 不能匹配 /api/v1/devicefoo @@ -573,7 +602,9 @@ func (h *Handler) listProxyServices(scheme, hostPort, portalHost string) []proxy if r.Err == "" { e.URL = fmt.Sprintf("%s://%s.%s%s", scheme, r.Host, t.base, hostPort) if r.Path != "" { - e.URLPortal = fmt.Sprintf("%s://%s%s%s/", scheme, portalHost, hostPort, r.Path) + // portalHostWithPort 保证端口恰好出现一次(见其注释: + // 生产实例的 Host 自带 :8080,直接追加会拼出 8080:8080) + e.URLPortal = fmt.Sprintf("%s://%s%s/", scheme, portalHostWithPort(portalHost, hostPort), r.Path) } } out = append(out, e) @@ -767,21 +798,9 @@ func (h *Handler) handleProxyServices(w http.ResponseWriter, r *http.Request) { return } scheme, port := h.proxySchemeAndPort(r) - portalHost := r.Host + portalHost := portalHostWithPort(r.Host, port) if hh := r.Header.Get("X-Forwarded-Host"); hh != "" { - portalHost = strings.TrimSpace(strings.Split(hh, ",")[0]) - } - // 反代层看到的 Host 可能不含端口(nginx 默认剥掉),此时用监听端口补, - // 保证服务入口链接点得开。 - if _, _, err := net.SplitHostPort(portalHost); err != nil { - if p := strings.TrimPrefix(port, ":"); p != "" { - if portalHost == "" { - portalHost = "localhost" - } - if _, _, e2 := net.SplitHostPort(portalHost + ":" + p); e2 == nil { - portalHost = portalHost + ":" + p - } - } + portalHost = portalHostWithPort(strings.TrimSpace(strings.Split(hh, ",")[0]), port) } svcs := h.listProxyServices(scheme, port, portalHost) // 可达性探测:并发带超时,避免一个坏上游拖住整个清单。 @@ -908,9 +927,10 @@ func (h *Handler) handleDeviceGatewayDiscovery(w http.ResponseWriter, r *http.Re out["url"] = wsScheme + "://" + route.Host + "." + t.base + port + "/api/v1/device/ws" out["http_url"] = scheme + "://" + route.Host + "." + t.base + port // 门户同源形态:用**客户端实际访问用的 host**,保证它一定能解析。 - portalHost := r.Host + // portalHostWithPort 负责让端口恰好出现一次(Host 可能已带端口)。 + portalHost := portalHostWithPort(r.Host, port) if h := r.Header.Get("X-Forwarded-Host"); h != "" { - portalHost = strings.TrimSpace(strings.Split(h, ",")[0]) + portalHost = portalHostWithPort(strings.TrimSpace(strings.Split(h, ",")[0]), port) } if route.Path != "" { // 声明了路径挂载 ⇒ 门户同源形态就是它(无 DNS 依赖,设备客户端首选) diff --git a/internal/plugins/webui/proxy_test.go b/internal/plugins/webui/proxy_test.go index 08e6dde..1277311 100644 --- a/internal/plugins/webui/proxy_test.go +++ b/internal/plugins/webui/proxy_test.go @@ -1196,3 +1196,119 @@ func TestDiscoveryEndpointNotHijackedByPathMount(t *testing.T) { t.Errorf("设备路径未走反代: code=%d body=%q", rec2.Code, rec2.Body.String()) } } + +// ★ 服务入口链接的端口必须恰好出现一次。 +// +// 真实 bug(生产部署后立即暴露):请求 Host 自带端口(生产实测 Host 是 +// 127.0.0.1:8080),而无条件再追加监听端口,拼出 +// +// "http://127.0.0.1:8080:8080/api/v1/device/" ← 链接点不开 +// +// 单测抓不到的原因:此前测试用的 Host 不含端口。补上这条覆盖两种输入。 +func TestPortalHostPortExactlyOnce(t *testing.T) { + cases := []struct{ host, port, want string }{ + // Host 已带端口 → 不得重复追加 + {"127.0.0.1:8080", ":8080", "127.0.0.1:8080"}, + {"portal.example.com:443", ":8080", "portal.example.com:443"}, + // Host 不含端口 → 补上监听端口 + {"127.0.0.1", ":8080", "127.0.0.1:8080"}, + {"portal.example.com", ":18080", "portal.example.com:18080"}, + // 空输入 → 兜底 localhost + {"", ":8080", "localhost:8080"}, + // 端口为空 → 原样(由调用方/scheme 决定默认端口) + {"portal.example.com", "", "portal.example.com"}, + // 端口号不带冒号也要能处理 + {"127.0.0.1", "8080", "127.0.0.1:8080"}, + } + for _, c := range cases { + if got := portalHostWithPort(c.host, c.port); got != c.want { + t.Errorf("portalHostWithPort(%q, %q) = %q,期望 %q", c.host, c.port, got, c.want) + } + } +} + +// 端到端:Host 自带端口时,服务入口的两种形态都必须可点(无重复端口)。 +func TestProxyServiceURLsWithPortInHost(t *testing.T) { + prev := declProvider + SetProxyDeclProvider(func() []proxyDecl { + return []proxyDecl{{ + Plugin: "remotedevice", Name: "gateway", Host: "devices", + Path: "/api/v1/device", Target: "127.0.0.1:9890", + WebSocket: true, Auth: sdk.ProxyAuthNone, + }} + }) + manualProxyRoutes = "" + InvalidateProxyRoutes() + t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() }) + + h := NewHandler(proxyTestSettings(t)) + + // 模拟生产:请求 Host 自带端口 + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/api/v1/proxy/services", nil) + r.Host = "127.0.0.1:8080" + r.Header.Set("X-API-Key", "test-api-key") + h.handleProxyServices(rec, r) + + var out struct { + Services []struct { + URL string `json:"url"` + URLPortal string `json:"url_portal"` + } `json:"services"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if len(out.Services) != 1 { + t.Fatalf("服务数 = %d", len(out.Services)) + } + if s := out.Services[0].URLPortal; strings.Contains(s, "8080:8080") { + t.Errorf("url_portal 端口重复: %q", s) + } + if s := out.Services[0].URLPortal; s != "http://127.0.0.1:8080/api/v1/device/" { + t.Errorf("url_portal = %q", s) + } + // 子域形态也必须只有一次端口 + if s := out.Services[0].URL; strings.Contains(s, "8080:8080") { + t.Errorf("url 端口重复: %q", s) + } +} + +// 发现端点在 Host 自带端口时同样不得拼重复端口。 +func TestDeviceGatewayDiscoveryNoDuplicatePort(t *testing.T) { + prev := declProvider + SetProxyDeclProvider(func() []proxyDecl { + return []proxyDecl{{ + Plugin: "remotedevice", Name: "gateway", Host: "devices", + Path: "/api/v1/device", Target: "127.0.0.1:9890", + WebSocket: true, Auth: sdk.ProxyAuthNone, + }} + }) + manualProxyRoutes = "" + InvalidateProxyRoutes() + t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() }) + + h := NewHandler(proxyTestSettings(t)) + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/api/v1/device/gateway", nil) + r.Host = "127.0.0.1:8080" + // 不设 X-Forwarded-*:用最朴素的场景(真实生产直连就是这样) + h.handleDeviceGatewayDiscovery(rec, r) + + var got struct { + URL string `json:"url"` + URLPortal string `json:"url_portal"` + HTTPURL string `json:"http_url"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + for name, v := range map[string]string{"url": got.URL, "url_portal": got.URLPortal, "http_url": got.HTTPURL} { + if strings.Contains(v, "8080:8080") { + t.Errorf("%s 端口重复: %q", name, v) + } + } + if got.URLPortal != "ws://127.0.0.1:8080/api/v1/device/ws" { + t.Errorf("url_portal = %q", got.URLPortal) + } +}