mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-29 06:00:56 +00:00
fix(webui): 服务入口「打开」改用路径形态 + 别名模式不补尾斜杠
验收时发现的**用户可见缺口**: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 包全绿。
This commit is contained in:
@ -3835,9 +3835,17 @@
|
||||
var token = state.settings?.["plugin.webui.api_key"] || "";
|
||||
html +=
|
||||
'<div style="font-size:12px;color:var(--text-muted);margin-bottom:10px">' +
|
||||
__("插件服务经 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 ",
|
||||
) +
|
||||
"<b>" +
|
||||
escHtml("*." + base) +
|
||||
"</b>" +
|
||||
__(
|
||||
";路径形态无 DNS 依赖,穿透场景下更可靠(点「打开」优先用它)。",
|
||||
"; the path form has no DNS dependency and is more reliable behind a tunnel (Open prefers it).",
|
||||
) +
|
||||
"</div>";
|
||||
html += '<div class="svc-list">';
|
||||
svcs.forEach(function (s) {
|
||||
@ -3858,13 +3866,30 @@
|
||||
"</span>"
|
||||
: "") +
|
||||
'<span class="svc-path">' + escHtml(s.host + "." + base) + "</span>";
|
||||
if (s.ok && url) {
|
||||
// 「打开」优先用**路径形态**(url_portal):
|
||||
// - 它没有 DNS 依赖,单端口穿透 / 子域无证书时都能用;
|
||||
// - 子域形态要求 DNS 能解析 <host>.<基域名>,而这些域名
|
||||
// 在外部常常不可达(实测:外层只放行一个 Host,三级子域
|
||||
// 因通配证书不匹配而握手失败)。
|
||||
// 保留子域链接作为次选(局域网内直连时它更直观)。
|
||||
var primary = s.url_portal || url;
|
||||
if (s.ok && primary) {
|
||||
html +=
|
||||
'<a class="btn btn-primary btn-sm" style="margin-left:auto" target="_blank" rel="noopener" href="' +
|
||||
escHtml(url) +
|
||||
escHtml(primary) +
|
||||
'">' +
|
||||
__("打开", "Open") +
|
||||
"</a>";
|
||||
if (url && s.url_portal && url !== s.url_portal) {
|
||||
html +=
|
||||
'<a class="btn btn-ghost btn-sm" target="_blank" rel="noopener" title="' +
|
||||
escHtml(url) +
|
||||
'" href="' +
|
||||
escHtml(url) +
|
||||
'">' +
|
||||
__("子域", "subdomain") +
|
||||
"</a>";
|
||||
}
|
||||
} else {
|
||||
html +=
|
||||
'<span class="svc-err" title="' +
|
||||
|
||||
@ -732,7 +732,18 @@ func (h *Handler) listProxyServices(scheme, hostPort, portalHost, domain string)
|
||||
if r.Path != "" {
|
||||
// portalHostWithPort 保证端口恰好出现一次(见其注释:
|
||||
// 生产实例的 Host 自带 :8080,直接追加会拼出 8080:8080)
|
||||
e.URLPortal = fmt.Sprintf("%s://%s%s/", scheme, portalHostWithPort(portalHost, hostPort), r.Path)
|
||||
//
|
||||
// 尾斜杠只给**前缀模式**:那是它的规范形态(访问无尾斜杠会被
|
||||
// 301 补上,见 redirectToTrailingSlash),而且路径形态的前端
|
||||
// 靠它算相对路径基准。
|
||||
// 别名模式**不能**加:那里的 path 是上游真实路径语义
|
||||
// (/api/v1/device 是给 /api/v1/device/xxx 做前缀的),
|
||||
// 补成 /api/v1/device/ 会让人以为有个可访问的根。
|
||||
suffix := ""
|
||||
if r.StripPath {
|
||||
suffix = "/"
|
||||
}
|
||||
e.URLPortal = fmt.Sprintf("%s://%s%s%s", scheme, portalHostWithPort(portalHost, hostPort), r.Path, suffix)
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
|
||||
@ -1268,8 +1268,11 @@ func TestProxyServiceURLsWithPortInHost(t *testing.T) {
|
||||
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)
|
||||
// 这条路由是**别名模式**(设备网关,客户端已硬编码 /api/v1/device/ws),
|
||||
// 所以不能补尾斜杠 —— path 在这里是上游真实路径语义,补了会让人
|
||||
// 以为存在一个 /api/v1/device/ 的根。
|
||||
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") {
|
||||
@ -1641,3 +1644,107 @@ func TestProxyStripPathRedirectsToTrailingSlash(t *testing.T) {
|
||||
t.Errorf("别名模式应原样转发 /api/v1/device,实际 %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 服务入口必须给用户**能用**的那个链接 ----
|
||||
//
|
||||
// 真实验收里发现的用户可见缺口:API 同时返回 url(子域)与 url_portal
|
||||
// (路径),但前端只用了 url —— 而子域形态在穿透部署下**恰恰是坏的**
|
||||
// (外层只放行一个 Host、三级子域证书不匹配)。
|
||||
// 用户点「打开」得到的是打不开的地址,还以为是插件的问题。
|
||||
func TestProxyServicesOffersBothForms(t *testing.T) {
|
||||
prev := declProvider
|
||||
SetProxyDeclProvider(func() []proxyDecl {
|
||||
return []proxyDecl{{
|
||||
Plugin: "huawei_smarthome", Name: "ui", Host: "huawei-smarthome",
|
||||
Path: "/p/huawei", StripPath: true,
|
||||
Target: "127.0.0.1:12100", Auth: sdk.ProxyAuthHomeAgent,
|
||||
}}
|
||||
})
|
||||
manualProxyRoutes = ""
|
||||
InvalidateProxyRoutes()
|
||||
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
|
||||
|
||||
cfgReg := internalConfig.NewConfigRegistry("")
|
||||
seedWebUIConfig(cfgReg)
|
||||
cfgReg.PluginConfig("webui").Set("base_url", "https://homeagent.example.com")
|
||||
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 {
|
||||
EntryURL string `json:"entry_url"`
|
||||
Services []struct {
|
||||
URL string `json:"url"`
|
||||
URLPortal string `json:"url_portal"`
|
||||
StripPath bool `json:"strip_path"`
|
||||
Path string `json:"path"`
|
||||
} `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Services) != 1 {
|
||||
t.Fatalf("期望 1 条服务,实际 %d", len(out.Services))
|
||||
}
|
||||
s := out.Services[0]
|
||||
|
||||
// 两种形态都必须给出:路径形态给无 DNS 依赖的客户端/穿透场景,
|
||||
// 子域形态给局域网内浏览器。缺一个就会有用户点不开。
|
||||
if s.URLPortal != "https://homeagent.example.com/p/huawei/" {
|
||||
t.Errorf("url_portal = %q,路径形态缺失或不是入口下的地址", s.URLPortal)
|
||||
}
|
||||
if s.URL != "https://huawei-smarthome.homeagent.example.com" {
|
||||
t.Errorf("url = %q,子域形态应为 <host>.<入口主机名>", 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user