Files
HomeAgent/third_party/homeagent-sdk/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

250 lines
10 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 (
"net"
"strconv"
"strings"
)
// 反向代理声明:插件告诉 HomeAgent「我起了个 HTTP 服务,请把它反代出去」。
//
// 为什么需要:插件自带 Web UI / HTTP API 时,监听地址在插件自己的配置里
// (如 127.0.0.1:12100),外部无从得知;而 webui 的对外端口通常只有一个
// (默认 :8080,且常经 frp 单端口隧道穿透)。没有声明机制时,用户只能
// 「知道端口 + 自己配转发」,插件换端口就失效。
//
// 设计取舍——**声明式而非注册式**:声明写在 plugin.json 里,由 HomeAgent
// 在加载插件时读取聚合,而不是让插件在运行期调 API 注册。理由:
// 1. 静态可发现:未启动/已崩溃的插件,其服务声明依然可见(可给出准确报错
// 「插件 X 声明了 ui 但目标 127.0.0.1:12100 不可达」,而不是静默 404);
// 2. 可版本化:声明随插件包一起分发、可 diff、可审计;
// 3. 旧内核无害:manifest 解析忽略未知字段,未支持该能力的 HomeAgent 读旧
// 插件、或旧 HomeAgent 读新插件都不会报错。
//
// 安全性:**不声明 = 不被反代**。声明本身就是能力声明,因此不需要在
// capabilities 里另外开一个开关——最小权限默认生效。
//
// 反代路径:HomeAgent 按 Host 路由(子域名标签 → Target),而非路径前缀。
// 理由:插件前端普遍使用根绝对路径(`fetch('/api/status')`),放在路径前缀
// 下会被劫持到 HomeAgent 自己的路由上;Host 路由下根路径天然正确,
// 插件前端**零改动**。这也让「只穿透一个端口」成立:同一端口按 Host 分发。
type ProxyDecl struct {
// Name 是同一插件内多条声明的唯一标识(如 "ui"、"api")。
// 省略时由 HomeAgent 按声明顺序补 "default"/"ui"/"api"... 仅用于展示与日志。
Name string `json:"name,omitempty"`
// Host 是**子域名标签**(不含基域名),如 "huawei" 对应 huawei.<基域名>。
//
// 约束:仅小写字母、数字与连字符,不以连字符开头/结尾,长度 ≤ 63
// (DNS label 规则)。省略时默认取插件名(下划线转连字符,因为下划线
// 不是合法 DNS label 字符)。
//
// 冲突处理:两个插件声明同一 Host 时,HomeAgent 不做「后者覆盖前者」——
// 那样会让先声明者静默消失。冲突条目被拒绝并在反代表里记录原因。
Host string `json:"host,omitempty"`
// Target 是上游地址,形如 "127.0.0.1:12100" 或 "http://127.0.0.1:12100"。
// 可带路径前缀(如 "127.0.0.1:3000/base"),HomeAgent 转发时保留该前缀。
//
// 端口由插件自己填它**实际监听**的地址,避免「声明与实际漂移」。
Target string `json:"target"`
// WebSocket 表示该服务需要 WebSocket 升级透传(默认 false)。
//
// 为什么必须显式声明而不是「有 Upgrade 头就转」:WS 是长连接,会占用
// 反代侧连接与 goroutine,且绕过普通请求的响应缓冲/超时逻辑。默认关闭
// 让普通 HTTP 服务的失败模式保持简单;未声明时的升级请求会被明确拒绝,
// 而不是静默降级成普通请求(后者表现为前端一直重连、排查困难)。
WebSocket bool `json:"websocket,omitempty"`
// Auth 决定这条反代由谁保护,取值见 ProxyAuthNone / ProxyAuthHomeAgent。
// 空串等价于 ProxyAuthHomeAgent(默认安全)。
//
// 为什么做成可声明项:设备网关(remotedevice)这类服务的调用方是**设备**,
// 它们不可能持有浏览器会话 cookie,而服务自身已有接入令牌(如 ws_token)。
// 强制走 HomeAgent 门户鉴权会把这类链路挡死;反过来,插件自带的 UI 若
// 声明 none,就等于把管理界面裸露给任何能访问该端口的人。
// 因此必须由插件**逐条**声明,而不是全局一刀切。
Auth string `json:"auth,omitempty"`
}
// ProxyAuth 取值。空串按 ProxyAuthHomeAgent 处理(安全的默认)。
const (
// ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话
// (homeagent_session cookie),非浏览器客户端走 X-API-Key。
// 两者都没有时返回 401,而不是把请求透传给上游。
ProxyAuthHomeAgent = "homeagent"
// ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。
//
// 适用场景:上游自己有鉴权且调用方不是浏览器(设备/嵌入式客户端),
// 或上游是刻意公开的服务。选用它意味着**信任上游自身的鉴权**,
// 且该服务在网络层可达范围内对所有人开放。
ProxyAuthNone = "none"
)
// ValidProxyAuth 校验 Auth 取值;空串合法(等价 ProxyAuthHomeAgent)。
func ValidProxyAuth(auth string) bool {
switch auth {
case "", ProxyAuthHomeAgent, ProxyAuthNone:
return true
}
return false
}
// EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHomeAgent)。
func EffectiveProxyAuth(auth string) string {
if auth == "" {
return ProxyAuthHomeAgent
}
return auth
}
// ValidProxyHostLabel 校验子域名标签是否合法(DNS label 规则)。
//
// 独立成导出函数:插件作者在写声明时、HomeAgent 在加载时、工具链在打包时
// 都要用同一套规则判定,避免三处各写一份而互相不一致。
func ValidProxyHostLabel(label string) bool {
if label == "" || len(label) > 63 {
return false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
default:
return false
}
}
return true
}
// NormalizeProxyHost 由插件名派生默认 Host 标签。
//
// 下划线转连字符:插件名允许下划线(huawei_smarthome),但 DNS label 不允许,
// 直接用会导致该子域名无法解析——这里统一转换,避免每个插件各自碰运气。
func NormalizeProxyHost(pluginName string) string {
s := strings.ToLower(strings.TrimSpace(pluginName))
s = strings.ReplaceAll(s, "_", "-")
// 去掉其它非法字符,保证结果是合法 label(宁可退化成保守值也不产出非法域名)
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
b.WriteByte(c)
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "plugin"
}
if len(out) > 63 {
out = strings.Trim(out[:63], "-")
}
return out
}
// ValidateProxyDecl 校验一条反代声明,返回人类可读的错误说明(合法时为空)。
//
// 为什么要在 SDK 里做校验:HomeAgent 加载插件时必须能明确拒绝坏声明并说明
// 原因(而不是静默忽略导致用户以为配好了);插件作者也需要在本地就能查出
// 拼错的 Target/Host。同一套规则两端共用。
func ValidateProxyDecl(d ProxyDecl) string {
if strings.TrimSpace(d.Target) == "" {
return "target 为空:必须给出上游地址(如 127.0.0.1:12100 或 http://127.0.0.1:12100)"
}
if !ValidProxyAuth(d.Auth) {
return "auth 取值非法:" + d.Auth + "(只允许 \"\" / \"homeagent\" / \"none\")"
}
if d.Host != "" && !ValidProxyHostLabel(d.Host) {
return "host 不是合法的子域名标签(只允许小写字母/数字/连字符,且不以连字符开头结尾): " + d.Host
}
// Target 的 host:port 部分必须可解析;路径前缀允许保留。
//
// 规则(刻意从严,因为地址写错是最常见的声明错误,而错误的反代会把
// 用户带到别处去):
// - 带 scheme 时(http://…)允许省略端口,由反代层按 scheme 补默认值;
// - 不带 scheme 时必须给出 host:port;
// - 端口必须是数字(SplitHostPort 本身不校验数字,"host:abc" 会通过)。
scheme := ""
raw := d.Target
if i := strings.Index(raw, "://"); i >= 0 {
scheme = strings.ToLower(raw[:i])
if scheme != "http" && scheme != "https" {
return "target scheme 只支持 http/https(WS 由 websocket 字段声明,不写 ws://): " + d.Target
}
raw = raw[i+3:]
}
if i := strings.IndexByte(raw, '/'); i >= 0 {
raw = raw[:i]
}
if raw == "" {
return "target 缺少主机部分: " + d.Target
}
host, port, err := net.SplitHostPort(raw)
if err != nil {
if scheme == "" {
return "target 必须给出 host:port(或带 http:// 前缀以便省略端口): " + d.Target
}
// 带 scheme 且解析失败:只剩主机名一种合法情形。
host, port = raw, ""
}
if host == "" {
return "target 缺少主机部分: " + d.Target
}
if port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return "target 端口非法(应为 1-65535 的数字): " + d.Target
}
}
return ""
}
// ProxyDeclarer 是内核注入的「收集反代声明」回调。
//
// 为什么需要运行期通道(明明主要走 plugin.json 自动发现):**内置插件**
// (编译进内核、没有独立插件目录与 plugin.json,如 remotedevice)无法靠
// 扫目录发现自己的服务;而它们恰恰最需要被反代出去(设备网关就是内置的)。
// 两种来源互补:
// - 外部插件 → plugin.json 的 proxies(静态、未启动也可见)
// - 内置插件 → DeclareProxy(运行期,随 Start 注册)
type ProxyDeclarer func(decl ProxyDecl)
// SetProxyDeclarer 由内核注入收集回调。插件不直接调它。
func (s *PluginSDK) SetProxyDeclarer(d ProxyDeclarer) {
if s == nil {
return
}
s.apiMu.Lock()
s.proxyDecl = d
s.apiMu.Unlock()
}
// DeclareProxy 声明本插件的一个服务需要 HomeAgent 反代出去。
//
// 用法(通常在 Start 里调用):
//
// s.DeclareProxy(sdk.ProxyDecl{
// Name: "ui", Host: "myapp", Target: "127.0.0.1:12100",
// })
//
// 声明立即生效(反代表会在下一次请求时重建)。声明**不做去重**:同一 Host
// 被两条声明占用时由反代层判定冲突并明确报错,而不是这里静默吞掉——
// 插件作者需要看见冲突。
func (s *PluginSDK) DeclareProxy(decl ProxyDecl) {
if s == nil {
return
}
s.apiMu.Lock()
d := s.proxyDecl
s.apiMu.Unlock()
if d != nil {
d(decl)
}
}