mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +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 败,且本改动完全
未触及该包),非本次引入。
110 lines
3.8 KiB
Go
110 lines
3.8 KiB
Go
package sdk
|
||
|
||
import (
|
||
"strings"
|
||
"sync"
|
||
|
||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||
)
|
||
|
||
// 反代声明在 **公开 SDK** 里定义(`pubsdk`),内核侧只是别名转发。
|
||
//
|
||
// 为什么放公开 SDK 而不是内核实现在:声明是**插件作者直接书写的契约**
|
||
// (plugin.json 的 proxies 字段),必须与 SDK 文档、hmapdev 工具链用同一套
|
||
// 定义与校验,否则插件作者本地通过、内核拒绝,或反之。
|
||
//
|
||
// 这与 ConfigDef 等信息完全同构——公开面定义契约,内核面实现行为。
|
||
|
||
// ProxyDecl 是一条反代声明(见 pubsdk.ProxyDecl 的完整文档)。
|
||
type ProxyDecl = pubsdk.ProxyDecl
|
||
|
||
// 生效的鉴权模式取值。
|
||
const (
|
||
// ProxyAuthHomeAgent:由 HomeAgent 统一保护(门户会话或 X-API-Key)。
|
||
ProxyAuthHomeAgent = pubsdk.ProxyAuthHomeAgent
|
||
// ProxyAuthNone:不经 HomeAgent 鉴权,信任上游自身鉴权。
|
||
ProxyAuthNone = pubsdk.ProxyAuthNone
|
||
)
|
||
|
||
// ValidProxyAuth 校验鉴权模式取值(空串合法,等价 ProxyAuthHomeAgent)。
|
||
func ValidProxyAuth(auth string) bool { return pubsdk.ValidProxyAuth(auth) }
|
||
|
||
// EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHomeAgent)。
|
||
func EffectiveProxyAuth(auth string) string { return pubsdk.EffectiveProxyAuth(auth) }
|
||
|
||
// ValidProxyHostLabel 校验子域标签是否合法(DNS label 规则)。
|
||
func ValidProxyHostLabel(label string) bool { return pubsdk.ValidProxyHostLabel(label) }
|
||
|
||
// NormalizeProxyHost 由插件名派生默认的子域标签。
|
||
func NormalizeProxyHost(pluginName string) string { return pubsdk.NormalizeProxyHost(pluginName) }
|
||
|
||
// ValidateProxyDecl 校验一条声明,返回人类可读的错误(合法时为空)。
|
||
func ValidateProxyDecl(d ProxyDecl) string { return pubsdk.ValidateProxyDecl(d) }
|
||
|
||
// ---- 内置插件反代声明的运行期登记表 ----
|
||
|
||
// 为什么需要它:外部插件的声明在 plugin.json 里,可以扫目录发现;但**内置**
|
||
// 插件编译进内核、没有插件目录,靠扫盘永远发现不了自己的服务——而设备网关
|
||
// (remotedevice)正是内置的,且最需要被反代出去。两种来源互补。
|
||
var (
|
||
builtinProxyMu sync.RWMutex
|
||
builtinProxyDecls = map[string][]ProxyDecl{}
|
||
builtinProxyVer int64
|
||
)
|
||
|
||
// DeclareBuiltinProxy 登记一个内置插件的服务声明(由 DeclareProxy 转发)。
|
||
func DeclareBuiltinProxy(plugin string, d ProxyDecl) {
|
||
if plugin == "" || strings.TrimSpace(d.Target) == "" {
|
||
return
|
||
}
|
||
builtinProxyMu.Lock()
|
||
defer builtinProxyMu.Unlock()
|
||
// 同一插件同一声明名重复登记(如自动重启后再次 Start)视为刷新,不重复累积。
|
||
name := d.Name
|
||
if name == "" {
|
||
name = "service"
|
||
d.Name = name
|
||
}
|
||
list := builtinProxyDecls[plugin]
|
||
for i := range list {
|
||
if list[i].Name == name {
|
||
list[i] = d
|
||
builtinProxyVer++
|
||
return
|
||
}
|
||
}
|
||
builtinProxyDecls[plugin] = append(list, d)
|
||
builtinProxyVer++
|
||
}
|
||
|
||
// ClearBuiltinProxyDecls 清除某插件的声明(插件停止/卸载时调用)。
|
||
func ClearBuiltinProxyDecls(plugin string) {
|
||
builtinProxyMu.Lock()
|
||
defer builtinProxyMu.Unlock()
|
||
if _, ok := builtinProxyDecls[plugin]; ok {
|
||
delete(builtinProxyDecls, plugin)
|
||
builtinProxyVer++
|
||
}
|
||
}
|
||
|
||
// BuiltinProxyDecls 返回内置插件声明的快照(plugin → decls)。
|
||
func BuiltinProxyDecls() map[string][]ProxyDecl {
|
||
builtinProxyMu.RLock()
|
||
defer builtinProxyMu.RUnlock()
|
||
out := make(map[string][]ProxyDecl, len(builtinProxyDecls))
|
||
for k, v := range builtinProxyDecls {
|
||
cp := make([]ProxyDecl, len(v))
|
||
copy(cp, v)
|
||
out[k] = cp
|
||
}
|
||
return out
|
||
}
|
||
|
||
// BuiltinProxyVersion 是声明表的版本号。调用方(webui 反代层)据它判断
|
||
// 缓存的路由表是否过期——比每次请求重新聚合一遍便宜得多。
|
||
func BuiltinProxyVersion() int64 {
|
||
builtinProxyMu.RLock()
|
||
defer builtinProxyMu.RUnlock()
|
||
return builtinProxyVer
|
||
}
|