mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-29 22:12:52 +00:00
用户要求:外部只装 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 败,且本改动完全
未触及该包),非本次引入。
112 lines
3.6 KiB
Go
112 lines
3.6 KiB
Go
package sdk
|
||
|
||
import "testing"
|
||
|
||
func TestProxyAuthDefaultsToHomeAgent(t *testing.T) {
|
||
// 空串必须归一化为「HomeAgent 统一保护」——这是安全默认。
|
||
// 若哪天有人把默认改成 none,这条会立刻红。
|
||
if got := EffectiveProxyAuth(""); got != ProxyAuthHomeAgent {
|
||
t.Fatalf("空 auth 应归一化为 %q,实际 %q", ProxyAuthHomeAgent, got)
|
||
}
|
||
if got := EffectiveProxyAuth(ProxyAuthNone); got != ProxyAuthNone {
|
||
t.Fatalf("显式 none 应保持 none,实际 %q", got)
|
||
}
|
||
for _, ok := range []string{"", ProxyAuthHomeAgent, ProxyAuthNone} {
|
||
if !ValidProxyAuth(ok) {
|
||
t.Errorf("%q 应合法", ok)
|
||
}
|
||
}
|
||
for _, bad := range []string{"nope", "HOMEAGENT", "None", "true"} {
|
||
if ValidProxyAuth(bad) {
|
||
t.Errorf("%q 应非法", bad)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestValidProxyHostLabel(t *testing.T) {
|
||
legit := []string{"huawei", "a", "a-b", "abc123", "0", "x" + string(make([]byte, 0)) + "yz"}
|
||
for _, s := range legit {
|
||
if !ValidProxyHostLabel(s) {
|
||
t.Errorf("%q 应为合法 label", s)
|
||
}
|
||
}
|
||
bad := []string{
|
||
"", "-a", "a-", "-", "a_b", "a.b", "A", "aB", "a b",
|
||
"a/b", "a:b", string(make([]byte, 64)), // 超长 63
|
||
}
|
||
for _, s := range bad {
|
||
if ValidProxyHostLabel(s) {
|
||
t.Errorf("%q 应为非法 label", s)
|
||
}
|
||
}
|
||
// 边界:恰好 63 合法,64 非法
|
||
l63 := ""
|
||
for i := 0; i < 63; i++ {
|
||
l63 += "a"
|
||
}
|
||
if !ValidProxyHostLabel(l63) {
|
||
t.Error("63 字符应为合法 label")
|
||
}
|
||
if ValidProxyHostLabel(l63 + "a") {
|
||
t.Error("64 字符应为非法 label")
|
||
}
|
||
}
|
||
|
||
func TestNormalizeProxyHost(t *testing.T) {
|
||
cases := map[string]string{
|
||
"huawei_smarthome": "huawei-smarthome", // 下划线不是合法 DNS label
|
||
"webui": "webui",
|
||
"UPPER_Case": "upper-case",
|
||
"a__b": "a--b",
|
||
"__x__": "x",
|
||
"---": "plugin", // 全非法 → 保守回退
|
||
"": "plugin",
|
||
"a.b.c": "abc",
|
||
}
|
||
for in, want := range cases {
|
||
if got := NormalizeProxyHost(in); got != want {
|
||
t.Errorf("NormalizeProxyHost(%q) = %q,期望 %q", in, got, want)
|
||
}
|
||
}
|
||
// 归一化结果必须自身合法(产物自洽)
|
||
for _, in := range []string{"huawei_smarthome", "UPPER_Case", "__x__", "a.b.c", "非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字"} {
|
||
if got := NormalizeProxyHost(in); !ValidProxyHostLabel(got) {
|
||
t.Errorf("NormalizeProxyHost(%q) = %q 不合法", in, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestValidateProxyDecl(t *testing.T) {
|
||
valid := []ProxyDecl{
|
||
{Target: "127.0.0.1:12100"},
|
||
{Target: "http://127.0.0.1:12100"},
|
||
{Target: "127.0.0.1:12100", Host: "huawei"},
|
||
{Target: "127.0.0.1:12100", Auth: ProxyAuthNone},
|
||
{Target: "127.0.0.1:12100", Auth: ProxyAuthHomeAgent, WebSocket: true},
|
||
{Target: "127.0.0.1:3000/base", Host: "x"},
|
||
{Target: "https://example.com", Host: "ext"}, // 远程上游也允许(由 auth 决定安全性)
|
||
}
|
||
for _, d := range valid {
|
||
if msg := ValidateProxyDecl(d); msg != "" {
|
||
t.Errorf("%+v 应合法,却报: %s", d, msg)
|
||
}
|
||
}
|
||
|
||
bad := []ProxyDecl{
|
||
{}, // 无 target
|
||
{Target: " "}, // 空白 target
|
||
{Target: "127.0.0.1:12100", Auth: "yes"}, // auth 非法
|
||
{Target: "127.0.0.1:12100", Host: "a_b"}, // host 非法
|
||
{Target: "127.0.0.1:12100", Host: "-x"},
|
||
{Target: "127.0.0.1:12100", Host: "X"},
|
||
{Target: "://12100"}, // 无主机
|
||
{Target: "http:///path"}, // 无主机
|
||
{Target: "127.0.0.1:notaport"}, // 端口非数字
|
||
}
|
||
for _, d := range bad {
|
||
if msg := ValidateProxyDecl(d); msg == "" {
|
||
t.Errorf("%+v 应被拒绝,却通过了", d)
|
||
}
|
||
}
|
||
}
|