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:
JianFeeeee
2026-09-25 12:13:03 +08:00
parent 4e40597954
commit 1e58af79d3
14 changed files with 2151 additions and 2 deletions

109
internal/sdk/proxy.go Normal file
View File

@ -0,0 +1,109 @@
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
}