feat(webui): 通用反向代理 —— 插件声明服务,HomeAgent 按子域反代出去

用户要求:外部只装 HomeAgent 即可使用自带反代能力;用户只需穿透一个
webui 端口就能访问所有内部插件服务;认证与 WebSocket 支持都作为插件
的可声明项;插件 UI 要有可直接点击的入口。

实测 huawei_smarthome 插件的前端用**根绝对路径**(api('/api/status') →
fetch('/api/status'))。挂在 /p/<name>/ 这类路径前缀下,这些请求会打到
HomeAgent 自己的 /api/status —— 静默错路由;做 HTML/JS 内容重写对拼进
JS 字符串的绝对路径只是"按概率能用",会产生"页面能开、某个按钮就坏"的
静默故障。子域路由下根路径天然正确,**插件前端零改动**。

且它天然匹配"只穿透一个端口":webui 监听 0.0.0.0:8080 按 Host 分发,
外层 frp 单端口 TCP 隧道**一行都不用改**。

默认基座 localhost:RFC 6761 规定 *.localhost 强制解析到 loopback,
现代浏览器原生支持 ⇒ <标签>.localhost:8080 **零配置可用**,不需要 DNS、
证书、/etc/hosts。远程部署改 base_domain 即可。

- 外部插件 → plugin.json 的 proxies(静态可发现:插件没起来也能报
  "声明了 ui 但目标不可达",而不是静默 404)
- 内置插件 → s.DeclareProxy()(remotedevice 是内置的、没有 plugin.json,
  却最需要被反代出去)

反代层在 webui 侧读清单:webui 已能拿到插件目录(PluginManager.PluginDir),
因此**无需给内核接口加方法**。manifest 解析忽略未知字段,加 proxies 对
"旧内核读新插件"与"新内核读旧插件"都无害。

新增 sdk/ProxyDecl 与配套校验(ValidProxyAuth / ValidProxyHostLabel /
NormalizeProxyHost / ValidateProxyDecl);新增运行期 ProxyDeclarer 通道。
hmapdev 的 writePluginJSON 是**白名单 map 重建**——不同步加字段会让声明
被打包静默丢弃(插件作者本地正常、装上去失效),因此 PlgConfig 与
writePluginJSON 同时加,并在打包前校验声明(插件作者本地就能发现写错)。

auth=homeagent(默认,安全的默认):门户会话 / X-API-Key / ?__token=;
auth=none:信任上游自身鉴权,供设备与嵌入式客户端使用——它们不可能持有
浏览器会话,强制走门户鉴权会把设备链路挡死。remotedevice 声明 none,
因为它自身用 ws_token 强制校验。

未声明时升级请求**明确拒绝**(400 + 原因),而不是静默降级成普通请求
(后者表现为前端不断重连、日志看不出原因)。

1. 不跟随上游 3xx:旧实现用 http.DefaultClient(默认跟最多 10 跳),
   上游 302 到内网地址时反代自己跟过去、失败回 502 并把内网 URL 泄给
   客户端。httputil.ReverseProxy 默认不跟随,3xx 原样透传。
2. 逐帧 flush:旧实现 io.Copy 导致上游流式响应被缓冲到上游关闭才下发
   (实测 3 帧 200ms 间隔的流,客户端在 +600ms 一次性收到全部)。
   设 FlushInterval=-1。

另补齐 X-Forwarded-For/Host/Proto(旧实现完全不注入,上游无法判断真实
来源),并剥掉上游 Set-Cookie 的 Domain(防止插件 cookie 打到主门户域)。

插件页新增「服务入口」卡片:列出全部被反代的插件服务(含被拒条目与
不可达原因),点「打开」直接访问。链接带 ?__token=<api_key>,因为子域
与门户不同源、浏览器不会自动带会话 cookie。

webui +35 条、SDK +4 条、工具链 +4 条。关键几条:
- 根绝对路径必须原样到上游(选 Host 路由的核心理由)
- 上游 302 必须原样透传、且反代不得跟随(旧缺陷)
- 已知 Content-Length 的慢速响应必须逐帧到达(**这条经过变异验证**:
  把 FlushInterval 改回 0 后判据挂死 → FAIL,还原后回绿。
  说明:最初写的 SSE/chunked 版本是假判据——ReverseProxy 对
  text/event-stream 与 ContentLength=-1 会自动立即 flush,与
  FlushInterval 无关,变异抓不到,已改正)
- 子域标签冲突不得静默覆盖(后者保留可见并带原因)
- 非法声明不进路由但必须可见(配置页要能看到原因)
- 未声明 websocket 的升级请求必须 400
- auth 逐条生效:none 放行匿名、homeagent 与默认档 401 且给可操作提示
- 自动发现:显式 host 不得被自动编号覆盖(**测试抓到的真 bug**:
  remotedevice 声明的 "devices" 会被改成 "devices-2" 而静默失效)
- 真实端到端:生产实例 huawei_smarthome 的 UI(9444 字节)与其
  /api/status 经反代正确透传

go build ./... 通过;相关包全量测试通过。
internal/plugin/proc 的 TestStreaming_PublishLatencyFlatAcrossSubscribers
是**预存在的不稳定测试**(同一份代码 10 次跑 9 过 1 败,且本改动完全
未触及该包),非本次引入。
This commit is contained in:
JianFeeeee
2026-09-25 12:13:03 +08:00
parent bcf99d0259
commit 5467a9f256
14 changed files with 2151 additions and 2 deletions

View File

@ -471,6 +471,9 @@
try {
state.installedPlugins = await api("/plugins");
} catch (e) {}
try {
await loadProxyServices();
} catch (e) {}
try {
await loadTerminals();
} catch (e) {}
@ -3803,12 +3806,96 @@
}
// ===== Plugins =====
// 「服务入口」:被反代的插件服务,点一下直接打开。
//
// 为什么单独一张卡而不是塞进每个插件的详情:入口是**跨插件**的(同一个
// webui 端口、不同子域),用户的心智是「我要打开某个服务」,不是
// 「我要进某个插件的管理页」。
//
// 链接带 ?__token=<api_key>:子域与门户不同源,浏览器不会带上会话 cookie;
// 不带 token 会 401(这是刻意的受保护默认)。页面已登录,此处复用同一把 key。
function renderProxyServicesCard() {
var svcs = state.proxyServices || [];
var base = state.proxyBaseDomain || "localhost";
var html =
'<div class="card"><h2>' +
__("服务入口", "Service Entry Points") +
"</h2>";
if (!svcs.length) {
return (
html +
'<p style="color:var(--text-muted);font-size:13px">' +
__(
"暂无被反代的插件服务。插件在 plugin.json 的 proxies 字段里声明后会自动出现在这里。",
"No proxied plugin services yet. Declare them in plugin.json's proxies field.",
) +
"</p></div>"
);
}
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.") +
"</div>";
html += '<div class="svc-list">';
svcs.forEach(function (s) {
var url = s.url || "";
if (url && token) url += "?__token=" + encodeURIComponent(token);
var dot = s.ok ? "var(--ok, #22c55e)" : "var(--danger, #d1383d)";
html +=
'<div class="svc-row">' +
'<span class="svc-dot" style="background:' + dot + '"></span>' +
'<span class="svc-name">' + escHtml(s.plugin_name || s.plugin) + "</span>" +
'<span class="svc-sub">' + escHtml(s.name) + "</span>" +
(s.websocket
? '<span class="svc-tag">WS</span>'
: "") +
(s.auth === "none"
? '<span class="svc-tag svc-tag-warn">' +
__("未保护", "unprotected") +
"</span>"
: "") +
'<span class="svc-path">' + escHtml(s.host + "." + base) + "</span>";
if (s.ok && url) {
html +=
'<a class="btn btn-primary btn-sm" style="margin-left:auto" target="_blank" rel="noopener" href="' +
escHtml(url) +
'">' +
__("打开", "Open") +
"</a>";
} else {
html +=
'<span class="svc-err" title="' +
escHtml(s.error || "") +
'">' +
escHtml(s.error || __("不可用", "unavailable")) +
"</span>";
}
html += "</div>";
});
html += "</div></div>";
return html;
}
async function loadProxyServices() {
try {
var d = await api("/proxy/services");
state.proxyServices = (d && d.services) || [];
state.proxyBaseDomain = (d && d.base_domain) || "localhost";
} catch (e) {
state.proxyServices = [];
}
}
function renderPlugins() {
var k = state.kernel;
var plugins = k?.plugins || [];
var tools = k?.tools || [];
var installed = state.installedPlugins || [];
var html =
var html = renderProxyServicesCard();
html +=
'<div class="card"><h2>' +
__("安装插件", "Install Plugin") +
"</h2>" +