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:
JianFeeeee
2026-09-25 16:28:34 +08:00
parent 323dabb701
commit 83548bcc16
3 changed files with 248 additions and 25 deletions

View File

@ -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"})