diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go index 5e663a0..ed344e2 100644 --- a/internal/plugins/webui/plugin.go +++ b/internal/plugins/webui/plugin.go @@ -235,7 +235,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { // 基域名:默认 localhost ⇒ <标签>.localhost:<端口> 开箱即用(RFC 6761 强制 // 解析到 loopback,无需 DNS/证书/hosts)。远程访问时改成本机可达的域名, // 如 webui.example.com ⇒ <标签>.webui.example.com。 - s.Settings().RegisterDef(sdk.ConfigDef{Key: "base_domain", Default: "localhost", Type: "string", DisplayName: "反代基域名", Description: "插件服务反代的基域名。默认 localhost,此时 <插件标签>.localhost:<端口> 直接可用;远程访问填如 webui.example.com", Category: "webui"}) + // 外部入口 base URL:经 frp/nginx 穿透时,请求 Host 往往是内网地址或 + // 缺少协议信息,而生成给用户的链接必须是**外部可点的**。填这里即覆盖。 + // 例:https://homeagent.example.com —— 生成的链接一律用它做协议+主机。 + s.Settings().RegisterDef(sdk.ConfigDef{Key: "base_url", Default: "", Type: "string", DisplayName: "外部入口 Base URL", Description: "经反代/穿透暴露给外部的完整入口地址(含协议),如 https://homeagent.example.com。留空则按请求推导(直连时正确;经多层网关时可能拼错)。填了它,所有生成的外部链接都用它", Category: "webui"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "base_domain", Default: "localhost", Type: "string", DisplayName: "反代基域名", Description: "插件服务按子域反代时用的基域名(如 webui.example.com 则 <插件标签>.webui.example.com)。默认 localhost 只对本机浏览器有效。若外层未放行子域,请改用手填反代条目或路径挂载", Category: "webui"}) // 手填反代条目(自动发现之外的补充):每行 `<标签> <上游地址> [ws] [auth=none]` s.Settings().RegisterDef(sdk.ConfigDef{Key: "proxy_routes", Default: "", Type: "text", DisplayName: "手填反代条目", Description: "每行一条:<子域标签> <上游地址> [ws] [auth=none|homeagent]。插件的 proxies 声明会自动发现,这里只用于补充未声明/第三方服务。例:grafana 127.0.0.1:3000", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_enabled", Default: "false", Type: "bool", DisplayName: "设备网关反代", Description: "启用后 /api/v1/device/* 反代到 remotedevice 插件(默认关闭,避免硬耦合)", Category: "webui"}) diff --git a/internal/plugins/webui/proxy.go b/internal/plugins/webui/proxy.go index 2eb7945..3122f5b 100644 --- a/internal/plugins/webui/proxy.go +++ b/internal/plugins/webui/proxy.go @@ -428,6 +428,78 @@ func portalHostWithPort(host, hostPort string) string { return h + p } +// baseURLSetting 读外部入口 base_url 设置(空 = 未配置,按请求推导)。 +func baseURLSetting(settings sdk.SettingsAPI) string { + if settings == nil { + return "" + } + v, err := settings.Get("base_url") + if err != nil || v == nil { + return "" + } + s, _ := v.(string) + return strings.TrimRight(strings.TrimSpace(s), "/") +} + +// resolveEntry 解析「对外入口」的 scheme / host(含端口)/ 子域基域名。 +// +// 三级优先,因为每种来源在不同部署下才可靠: +// +// 1. **配置项 base_url**(最可靠)。经 frp/nginx 穿透时,请求可能带内网 +// Host、或缺失协议,按请求推导会拼出用户点不开的链接。填了 base_url +// 就一律以它为准 —— 这是「webui 应当支持配置 baseurl」的直接诉求: +// 外部入口是**部署事实**,服务端不该靠猜。 +// +// 2. **X-Forwarded-Proto / X-Forwarded-Host**(反代层正确设置时可靠)。 +// 实测本项目外层 nginx/WAF 会带 X-Forwarded-Proto=https 与 +// X-Forwarded-Host=<外部域名>,于是不需要任何配置也能推出正确链接。 +// +// 3. **请求自身**(直连时的正确来源)。 +// +// 返回的 host 保证端口恰好出现一次(见 portalHostWithPort)。 +func (h *Handler) resolveEntry(r *http.Request) (scheme, host, domain string) { + _, port := h.proxySchemeAndPort(r) + + if bu := baseURLSetting(h.settings); bu != "" { + if u, err := url.Parse(bu); err == nil && u.Host != "" { + scheme = u.Scheme + if scheme == "" { + scheme = "https" + } + host = u.Host // 已含端口(若有) + // base_url 的**主机名**就是子域反代的基域名:外部入口是 + // homeagent.example.com 时,插件服务自然是 <标签>.homeagent.example.com。 + domain = u.Hostname() + return scheme, host, domain + } + } + + if p := r.Header.Get("X-Forwarded-Proto"); p != "" { + scheme = strings.ToLower(strings.TrimSpace(strings.Split(p, ",")[0])) + } else if r.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + if fh := r.Header.Get("X-Forwarded-Host"); fh != "" { + // 外层给了权威 Host:它的主机名即基域名。 + host = portalHostWithPort(strings.TrimSpace(strings.Split(fh, ",")[0]), port) + if hn := hostnameOf(host); hn != "" { + return scheme, host, hn + } + } + host = portalHostWithPort(r.Host, port) + return scheme, host, proxyBaseDomain(h.settings) +} + +// hostnameOf 去掉端口,返回纯主机名。 +func hostnameOf(hostport string) string { + if h, _, err := net.SplitHostPort(hostport); err == nil { + return h + } + return hostport +} + // matchProxyPath 按**最长前缀**匹配路径挂载的服务。 // // 边界要卡在路径分隔符上:/api/v1/device 不能匹配 /api/v1/devicefoo @@ -574,7 +646,15 @@ type proxyServiceEntry struct { // listProxyServices 汇总反代服务清单(含被拒条目,供配置页排错)。 // schemePort 由调用方按当前请求推导(本机 http:8080 / 远程 https:443 等)。 -func (h *Handler) listProxyServices(scheme, hostPort, portalHost string) []proxyServiceEntry { +// portOf 从 host:port 里取 ":port"(无端口返回空串)。 +func portOf(hostport string) string { + if _, p, err := net.SplitHostPort(hostport); err == nil { + return ":" + p + } + return "" +} + +func (h *Handler) listProxyServices(scheme, hostPort, portalHost, domain string) []proxyServiceEntry { t := currentProxyTable() metas := map[string]sdk.PluginMeta{} if h.pluginMgr != nil { @@ -600,7 +680,11 @@ func (h *Handler) listProxyServices(scheme, hostPort, portalHost string) []proxy e.PluginZh = r.Plugin } if r.Err == "" { - e.URL = fmt.Sprintf("%s://%s.%s%s", scheme, r.Host, t.base, hostPort) + baseForSub := t.base + if domain != "" { + baseForSub = domain + } + e.URL = fmt.Sprintf("%s://%s.%s%s", scheme, r.Host, baseForSub, hostPort) if r.Path != "" { // portalHostWithPort 保证端口恰好出现一次(见其注释: // 生产实例的 Host 自带 :8080,直接追加会拼出 8080:8080) @@ -797,12 +881,8 @@ func (h *Handler) handleProxyServices(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - scheme, port := h.proxySchemeAndPort(r) - portalHost := portalHostWithPort(r.Host, port) - if hh := r.Header.Get("X-Forwarded-Host"); hh != "" { - portalHost = portalHostWithPort(strings.TrimSpace(strings.Split(hh, ",")[0]), port) - } - svcs := h.listProxyServices(scheme, port, portalHost) + scheme, portalHost, domain := h.resolveEntry(r) + svcs := h.listProxyServices(scheme, portOf(portalHost), portalHost, domain) // 可达性探测:并发带超时,避免一个坏上游拖住整个清单。 var wg sync.WaitGroup for i := range svcs { @@ -819,15 +899,21 @@ func (h *Handler) handleProxyServices(w http.ResponseWriter, r *http.Request) { }(&svcs[i]) } wg.Wait() - base := "localhost" - if h.settings != nil { - base = proxyBaseDomain(h.settings) + // base_domain 优先报告**推导出的基域名**(base_url / X-Forwarded-Host / + // 配置项),它是前端拼子域链接的依据;只有推导不出时才回落配置项。 + base := domain + if base == "" { + base = "localhost" + if h.settings != nil { + base = proxyBaseDomain(h.settings) + } } writeJSON(w, http.StatusOK, map[string]interface{}{ "services": svcs, "base_domain": base, "scheme": scheme, - "port": port, + "port": portOf(portalHost), + "entry_url": scheme + "://" + portalHost, }) } @@ -888,7 +974,11 @@ func (h *Handler) handleDeviceGatewayDiscovery(w http.ResponseWriter, r *http.Re http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - scheme, port := h.proxySchemeAndPort(r) + // 用 resolveEntry 而非 proxySchemeAndPort:前者认得 base_url 与 + // X-Forwarded-*,能给出**外部可点**的入口;后者只看请求自身, + // 穿透场景下会拼出内网地址。 + scheme, portalHost, domain := h.resolveEntry(r) + port := portOf(portalHost) wsScheme := "ws" if scheme == "https" { wsScheme = "wss" @@ -923,15 +1013,14 @@ func (h *Handler) handleDeviceGatewayDiscovery(w http.ResponseWriter, r *http.Re // 无任何 DNS 依赖,永远可解析 ⇒ 设备客户端的正确选择。 // // 两者都是「同一个端口」,单端口穿透的前提不受影响。 - out["host"] = route.Host + "." + t.base - out["url"] = wsScheme + "://" + route.Host + "." + t.base + port + "/api/v1/device/ws" - out["http_url"] = scheme + "://" + route.Host + "." + t.base + port - // 门户同源形态:用**客户端实际访问用的 host**,保证它一定能解析。 - // portalHostWithPort 负责让端口恰好出现一次(Host 可能已带端口)。 - portalHost := portalHostWithPort(r.Host, port) - if h := r.Header.Get("X-Forwarded-Host"); h != "" { - portalHost = portalHostWithPort(strings.TrimSpace(strings.Split(h, ",")[0]), port) + baseForSub := t.base + if domain != "" { + baseForSub = domain } + out["host"] = route.Host + "." + baseForSub + out["url"] = wsScheme + "://" + route.Host + "." + baseForSub + port + "/api/v1/device/ws" + out["http_url"] = scheme + "://" + route.Host + "." + baseForSub + port + // 门户同源形态:portalHost 已由上面的 resolveEntry 给出(外部可点的入口)。 if route.Path != "" { // 声明了路径挂载 ⇒ 门户同源形态就是它(无 DNS 依赖,设备客户端首选) out["url_portal"] = wsScheme + "://" + portalHost + route.Path + "/ws" diff --git a/internal/plugins/webui/proxy_test.go b/internal/plugins/webui/proxy_test.go index 1277311..7c43995 100644 --- a/internal/plugins/webui/proxy_test.go +++ b/internal/plugins/webui/proxy_test.go @@ -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) + } + } +}