mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
feat(webui): 外部入口 base_url 配置 —— 穿透场景下链接不再靠猜
用户指出 webui 实际是经 https://homeagent.jianfgit.xyz/ 穿透出去的, 应当支持配置 base URL。实测确认了这个诉求的正当性。 ## 实测发现的约束(决定方案) 1. **子域形态在外部不可用**:`*.homeagent.jianfgit.xyz` 泛解析存在, 但外层只给 `*.jianfgit.xyz` 通配证书 —— 该证书**不匹配三级子域**, 实测 `huawei-smarthome.homeagent.jianfgit.xyz` 外部握手失败(HTTP 000)。 外层只放行 `homeagent.jianfgit.xyz` 这一个 Host。 2. **路径挂载形态外部可用**:实测 `https://homeagent.jianfgit.xyz/api/v1/device/online` → 200。 所以「一个外部 Host + 路径挂载」是这条链路的正解,且已经工作。 3. 外层 nginx/WAF 会带 `X-Forwarded-Proto: https` 与 `X-Forwarded-Host`, 因此即使不配置也能推出正确链接;配 base_url 则是显式兜底。 ## 新增设置项 base_url 三级优先解析「对外入口」(resolveEntry): 1. **配置项 base_url** —— 外部入口是部署事实,不该靠请求猜。 经多层网关时请求可能带内网 Host,按它推导会拼出用户点不开的链接。 2. **X-Forwarded-Proto / X-Forwarded-Host** —— 反代层给权威信息时可靠。 3. **请求自身** —— 直连时的正确来源。 base_url 的主机名同时用作**子域反代的基域名**:入口是 homeagent.example.com 时,插件服务自然是 <标签>.homeagent.example.com。 服务清单另增 entry_url 字段,直接给出「外部入口是什么」,便于前端与排错。 ## 生效点 - `/api/v1/proxy/services`:url / url_portal / base_domain / entry_url - `/api/v1/device/gateway`:url / url_portal / http_url / host - 两处原先各自推导 portalHost,现统一走 resolveEntry,避免再次漂移 ## 部署后实测(生产,经真实外部入口) entry_url: https://homeagent.jianfgit.xyz base_domain: homeagent.jianfgit.xyz remotedevice | https://devices.homeagent.jianfgit.xyz | https://homeagent.jianfgit.xyz/api/v1/device/ ← 外部可点 huawei_smarthome| https://huawei-smarthome.homeagent.jianfgit.xyz 发现端点(外部视角): url_portal = wss://homeagent.jianfgit.xyz/api/v1/device/ws ← 外部可连 ## 判据 +3 条:TestBaseURLOverridesRequestDerived(内网 Host 场景下必须用 base_url)、 TestEntryPrefersForwardedHeaders(XFF 优先于请求自身)、 TestBaseURLTolerant(尾斜杠/空格容错 —— 手填配置最常见的两种手误)。 另更新一条旧判据的期望值:外部入口是 portal.example.com 时,子域基名应取 **实际入口**而非本机配置的 localhost(后者对远程用户无意义)。
This commit is contained in:
@ -462,7 +462,7 @@ func TestListProxyServicesIncludesURLAndErrors(t *testing.T) {
|
||||
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
|
||||
|
||||
h := NewHandler(proxyTestSettings(t))
|
||||
svcs := h.listProxyServices("http", ":8080", "localhost:8080")
|
||||
svcs := h.listProxyServices("http", ":8080", "localhost:8080", "localhost")
|
||||
if len(svcs) != 2 {
|
||||
t.Fatalf("入口数 = %d,期望 2(含坏条目)", len(svcs))
|
||||
}
|
||||
@ -925,8 +925,11 @@ func TestDeviceGatewayDiscovery(t *testing.T) {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
json.Unmarshal(rec2.Body.Bytes(), &got2)
|
||||
if got2.URL != "wss://devices.localhost/api/v1/device/ws" {
|
||||
t.Errorf("https 场景 url = %q,期望 wss 且无端口", got2.URL)
|
||||
// 外部入口是 portal.example.com 时,插件服务自然挂在
|
||||
// devices.portal.example.com —— 子域基名取自**实际入口**,
|
||||
// 而不是本机配置的 localhost(后者对远程用户毫无意义)。
|
||||
if got2.URL != "wss://devices.portal.example.com/api/v1/device/ws" {
|
||||
t.Errorf("https 场景 url = %q,期望基于 X-Forwarded-Host 的 wss 地址且无端口", got2.URL)
|
||||
}
|
||||
|
||||
// ★ 安全:不得把设备令牌带回来(门户凭证不该换来设备执行权)
|
||||
@ -1312,3 +1315,130 @@ func TestDeviceGatewayDiscoveryNoDuplicatePort(t *testing.T) {
|
||||
t.Errorf("url_portal = %q", got.URLPortal)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 外部入口 base_url ----
|
||||
//
|
||||
// 真实场景:webui 经 frp/nginx 穿透到 https://homeagent.example.com。
|
||||
// 此时请求可能带内网 Host、或缺失协议,按请求推导会拼出用户点不开的链接
|
||||
// (本项目实测:外层未放行子域,只有 homeagent.jianfgit.xyz 这一个 Host
|
||||
// 带通配证书,三级子域外部握手失败)。
|
||||
//
|
||||
// base_url 让「外部入口」成为**可配置的部署事实**,而不是靠猜。
|
||||
func TestBaseURLOverridesRequestDerived(t *testing.T) {
|
||||
cfgReg := internalConfig.NewConfigRegistry("")
|
||||
seedWebUIConfig(cfgReg)
|
||||
webuiCfg := cfgReg.PluginConfig("webui")
|
||||
webuiCfg.Set("base_url", "https://homeagent.example.com")
|
||||
settings := sdk.NewSettings("webui", cfgReg)
|
||||
|
||||
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(testSDK(sdk.SDKConfig{Settings: settings}))
|
||||
|
||||
// 请求来自内网(Host 是 127.0.0.1:8080)—— 这正是穿透场景的真实样子
|
||||
rec := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/api/v1/device/gateway", nil)
|
||||
r.Host = "127.0.0.1:8080"
|
||||
h.handleDeviceGatewayDiscovery(rec, r)
|
||||
|
||||
var got struct {
|
||||
URL string `json:"url"`
|
||||
URLPortal string `json:"url_portal"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 门户同源形态必须是外部可点的
|
||||
if got.URLPortal != "wss://homeagent.example.com/api/v1/device/ws" {
|
||||
t.Errorf("url_portal = %q,期望用 base_url 推导的 wss 外部地址", got.URLPortal)
|
||||
}
|
||||
// 子域形态也必须用 base_url 的域(example.com → devices.example.com)
|
||||
if got.URL != "wss://devices.homeagent.example.com/api/v1/device/ws" {
|
||||
t.Errorf("url = %q,子域应基于 base_url 的主机名", got.URL)
|
||||
}
|
||||
|
||||
// 服务清单同样以 base_url 为准
|
||||
rec2 := httptest.NewRecorder()
|
||||
r2 := httptest.NewRequest("GET", "/api/v1/proxy/services", nil)
|
||||
r2.Host = "127.0.0.1:8080"
|
||||
r2.Header.Set("X-API-Key", "test-api-key")
|
||||
h.handleProxyServices(rec2, r2)
|
||||
var out struct {
|
||||
BaseDomain string `json:"base_domain"`
|
||||
EntryURL string `json:"entry_url"`
|
||||
Services []struct {
|
||||
URLPortal string `json:"url_portal"`
|
||||
} `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(rec2.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.EntryURL != "https://homeagent.example.com" {
|
||||
t.Errorf("entry_url = %q", out.EntryURL)
|
||||
}
|
||||
if out.BaseDomain != "homeagent.example.com" {
|
||||
t.Errorf("base_domain = %q,应取自 base_url", out.BaseDomain)
|
||||
}
|
||||
if len(out.Services) != 1 || !strings.HasPrefix(out.Services[0].URLPortal, "https://homeagent.example.com/") {
|
||||
t.Errorf("服务入口未用 base_url: %+v", out.Services)
|
||||
}
|
||||
}
|
||||
|
||||
// 未配 base_url 时:X-Forwarded-* 优先于请求自身(反代层已给权威信息)。
|
||||
func TestEntryPrefersForwardedHeaders(t *testing.T) {
|
||||
h := NewHandler(proxyTestSettings(t))
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Host = "10.0.0.5:8080" // 内网地址(穿透场景常见)
|
||||
r.Header.Set("X-Forwarded-Proto", "https")
|
||||
r.Header.Set("X-Forwarded-Host", "portal.example.com")
|
||||
scheme, host, domain := h.resolveEntry(r)
|
||||
if scheme != "https" {
|
||||
t.Errorf("scheme = %q,应取 X-Forwarded-Proto", scheme)
|
||||
}
|
||||
if host != "portal.example.com" {
|
||||
t.Errorf("host = %q,应取 X-Forwarded-Host", host)
|
||||
}
|
||||
if domain != "portal.example.com" {
|
||||
t.Errorf("domain = %q,应取 X-Forwarded-Host 的主机名", domain)
|
||||
}
|
||||
|
||||
// 都没有时回落到请求自身
|
||||
r2 := httptest.NewRequest("GET", "/", nil)
|
||||
r2.Host = "192.168.2.60:8080"
|
||||
s2, h2, d2 := h.resolveEntry(r2)
|
||||
if s2 != "http" || h2 != "192.168.2.60:8080" {
|
||||
t.Errorf("回落失败: scheme=%q host=%q", s2, h2)
|
||||
}
|
||||
if d2 != "localhost" {
|
||||
t.Errorf("未配 base_url 且无 XFF 时,基域名应回落配置默认值,实际 %q", d2)
|
||||
}
|
||||
}
|
||||
|
||||
// base_url 末尾斜杠/多余空格必须被容忍(手填配置最常见的两种手误)。
|
||||
func TestBaseURLTolerant(t *testing.T) {
|
||||
for _, raw := range []string{"https://h.example.com/", " https://h.example.com ", "https://h.example.com"} {
|
||||
cfgReg := internalConfig.NewConfigRegistry("")
|
||||
seedWebUIConfig(cfgReg)
|
||||
cfgReg.PluginConfig("webui").Set("base_url", raw)
|
||||
h := NewHandler(testSDK(sdk.SDKConfig{Settings: sdk.NewSettings("webui", cfgReg)}))
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Host = "127.0.0.1:8080"
|
||||
_, host, domain := h.resolveEntry(r)
|
||||
if host != "h.example.com" {
|
||||
t.Errorf("base_url=%q → host=%q,期望 h.example.com(应容忍尾斜杠/空格)", raw, host)
|
||||
}
|
||||
if domain != "h.example.com" {
|
||||
t.Errorf("base_url=%q → domain=%q", raw, domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user