mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +00:00
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:
@ -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>" +
|
||||
|
||||
Reference in New Issue
Block a user