diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index 91d923c..c62b9a0 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -3835,9 +3835,17 @@ var token = state.settings?.["plugin.webui.api_key"] || ""; html += '
' + - __("插件服务经 HomeAgent 同一端口反代,按子域区分(基域名 ", "Proxied through the same port, keyed by subdomain (base ") + - escHtml(base) + - __(")。点「打开」直接访问。", "). Click Open to visit.") + + __( + "插件服务经 HomeAgent 同一端口反代。子域形态需要 DNS 能解析 ", + "Proxied through the same port. The subdomain form needs DNS for ", + ) + + "" + + escHtml("*." + base) + + "" + + __( + ";路径形态无 DNS 依赖,穿透场景下更可靠(点「打开」优先用它)。", + "; the path form has no DNS dependency and is more reliable behind a tunnel (Open prefers it).", + ) + "
"; html += '
'; svcs.forEach(function (s) { @@ -3858,13 +3866,30 @@ "" : "") + '' + escHtml(s.host + "." + base) + ""; - if (s.ok && url) { + // 「打开」优先用**路径形态**(url_portal): + // - 它没有 DNS 依赖,单端口穿透 / 子域无证书时都能用; + // - 子域形态要求 DNS 能解析 .<基域名>,而这些域名 + // 在外部常常不可达(实测:外层只放行一个 Host,三级子域 + // 因通配证书不匹配而握手失败)。 + // 保留子域链接作为次选(局域网内直连时它更直观)。 + var primary = s.url_portal || url; + if (s.ok && primary) { html += '' + __("打开", "Open") + ""; + if (url && s.url_portal && url !== s.url_portal) { + html += + '' + + __("子域", "subdomain") + + ""; + } } else { html += '.<入口主机名>", s.URL) + } + // 前缀模式的入口必须带尾斜杠(否则浏览器算错相对路径基准) + if !strings.HasSuffix(s.URLPortal, "/") { + t.Errorf("strip_path 路由的 url_portal 必须以 / 结尾(相对路径基准),实际 %q", s.URLPortal) + } + // 前端据此决定要不要显示「子域」次选按钮 + if !s.StripPath || s.Path != "/p/huawei" { + t.Errorf("path/strip_path 未透出,前端无法区分两种开关: %+v", s) + } +} + +// 别名模式(设备网关)的 url_portal 不能加尾斜杠 —— 那会改坏上游路径语义。 +func TestProxyServicesAliasKeepsExactPath(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() }) + + cfgReg := internalConfig.NewConfigRegistry("") + seedWebUIConfig(cfgReg) + h := NewHandler(testSDK(sdk.SDKConfig{Settings: sdk.NewSettings("webui", cfgReg)})) + + 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 { + URLPortal string `json:"url_portal"` + } `json:"services"` + } + json.Unmarshal(rec.Body.Bytes(), &out) + if len(out.Services) != 1 { + t.Fatal("期望 1 条服务") + } + if strings.HasSuffix(out.Services[0].URLPortal, "/") { + t.Errorf("别名模式的 url_portal 不应以 / 结尾(那是上游真实路径语义):%q", + out.Services[0].URLPortal) + } +}