From 8af12e0138e76ca5aebfb49acb28b5c6b26dec7f Mon Sep 17 00:00:00 2001
From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com>
Date: Fri, 25 Sep 2026 17:25:08 +0800
Subject: [PATCH] =?UTF-8?q?fix(webui):=20=E6=9C=8D=E5=8A=A1=E5=85=A5?=
=?UTF-8?q?=E5=8F=A3=E3=80=8C=E6=89=93=E5=BC=80=E3=80=8D=E6=94=B9=E7=94=A8?=
=?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=BD=A2=E6=80=81=20+=20=E5=88=AB=E5=90=8D?=
=?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=8D=E8=A1=A5=E5=B0=BE=E6=96=9C=E6=9D=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
验收时发现的**用户可见缺口**:API 早就同时返回 url(子域)与 url_portal
(路径),但「服务入口」卡片只用了 url —— 而子域形态在穿透部署下
**恰恰是打不开的那个**(外层只放行一个 Host、三级子域通配证书不匹配)。
用户点「打开」得到坏链接,还会以为是插件的问题。
## 改动
1. 卡片「打开」优先 url_portal(路径形态):
无 DNS 依赖,单端口穿透 / 子域无证书时都能用。
子域链接保留为次选按钮(局域网内直连时更直观)。
文案补一句说明两者差别(子域需 DNS 能解析 `*.<基域名>`)。
2. **别名模式不再补尾斜杠**(顺带发现的 bug):
原实现给所有 url_portal 无条件加 `/`。前缀模式下对(那是规范形态,
前端靠它算相对路径基准);别名模式下错 —— 那里的 path 是上游真实
路径语义(/api/v1/device 是 /api/v1/device/xxx 的前缀),补成
/api/v1/device/ 会让人误以为存在一个可访问的根。
## 判据
+2 条:TestProxyServicesOffersBothForms(两种形态都必须给出,
且前缀模式的 url_portal 必须带尾斜杠)、
TestProxyServicesAliasKeepsExactPath(别名模式不得带尾斜杠)。
变异验证(2 条,均按预期打红后还原回绿):
- 别名模式也加尾斜杠 → 判红
- 前缀模式不加尾斜杠 → 判红
另修正一条旧判据的期望值:它当年断言的是「所有 url_portal 都带尾斜杠」
(即把 bug 当成契约钉住了)。那条路由正是设备网关(别名模式),
现在改为断言不补尾斜杠,并注明理由。
全量:35 包全绿。
---
internal/plugins/webui/dashboard.js | 35 +++++++--
internal/plugins/webui/proxy.go | 13 +++-
internal/plugins/webui/proxy_test.go | 111 ++++++++++++++++++++++++++-
3 files changed, 151 insertions(+), 8 deletions(-)
diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js
index 91d923c0..c62b9a02 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)
+ }
+}