Files
HomeAgent/internal/sdk/proxy_test.go
JianFeeeee 1e58af79d3 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 败,且本改动完全
未触及该包),非本次引入。
2026-09-26 13:49:59 +08:00

56 lines
2.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package sdk
import "testing"
// 内置插件的运行期声明必须真的被登记、可枚举、可清除——
// remotedevice(内置、无 plugin.json)就靠这条通道。
func TestBuiltinProxyDeclRegistry(t *testing.T) {
const p = "test_builtin_proxy"
ClearBuiltinProxyDecls(p)
defer ClearBuiltinProxyDecls(p)
before := BuiltinProxyVersion()
DeclareBuiltinProxy(p, ProxyDecl{Name: "gw", Host: "devices", Target: "127.0.0.1:9890", WebSocket: true, Auth: ProxyAuthNone})
if BuiltinProxyVersion() == before {
t.Error("登记后版本号应递增(反代层靠它判断缓存失效)")
}
got := BuiltinProxyDecls()
list := got[p]
if len(list) != 1 {
t.Fatalf("登记了 %d 条,期望 1: %+v", len(list), got)
}
if !list[0].WebSocket || list[0].Auth != ProxyAuthNone || list[0].Host != "devices" {
t.Errorf("声明内容不对: %+v", list[0])
}
// 重复登记同名(如自动重启后再次 Start)应为刷新而非累积
DeclareBuiltinProxy(p, ProxyDecl{Name: "gw", Host: "devices", Target: "127.0.0.1:9890", WebSocket: true, Auth: ProxyAuthNone})
if l := BuiltinProxyDecls()[p]; len(l) != 1 {
t.Errorf("重复登记应为刷新,实际累积成 %d 条", len(l))
}
// 空 target 必须被拒(不声明的默认就是不被反代,空声明更不该登记)
DeclareBuiltinProxy(p, ProxyDecl{Name: "bad", Target: " "})
if l := BuiltinProxyDecls()[p]; len(l) != 1 {
t.Errorf("空 target 不应被登记,实际 %d 条", len(l))
}
ClearBuiltinProxyDecls(p)
if _, ok := BuiltinProxyDecls()[p]; ok {
t.Error("清除后不应还有该插件的声明")
}
}
// DeclareProxy 必须把声明转发给内核注入的收集回调。
func TestDeclareProxyForwards(t *testing.T) {
var got []ProxyDecl
ps := New("demo", SDKConfig{})
ps.SetProxyDeclarer(func(d ProxyDecl) { got = append(got, d) })
ps.DeclareProxy(ProxyDecl{Name: "ui", Host: "demo", Target: "127.0.0.1:12100", WebSocket: false, Auth: ProxyAuthNone})
if len(got) != 1 || got[0].Host != "demo" || got[0].Auth != ProxyAuthNone {
t.Fatalf("声明未转发到收集回调: %+v", got)
}
}