Files
HomeAgent/internal/sdk/proxy.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

110 lines
3.8 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 (
"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
}