mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 05:13:27 +00:00
用户要求用方案 A(路径挂载)让 huawei 插件 UI 在外部可用,
并把「通过反代的插件必须使用单一入口」写入 SDK 声明。
## 实测暴露的两个真问题
1. **Path 的语义不能一刀切**。原设计「原样保留」只对**机器接口**成立
(设备客户端硬编码 /api/v1/device/ws,不可能知道反代的存在);
而自带 UI 的服务需要**剥掉前缀**(/p/huawei/api/status → 上游 /api/status)。
猜错的结果是全部请求 404,且看起来像上游故障 —— 所以由声明者选:
strip_path=false 别名模式 / true 前缀模式。非法组合被 validate 挡住。
2. **前缀模式的尾斜杠是必需的**(自测发现的 bug)。
访问 /p/huawei(无尾斜杠)时页面能开,但页面里所有 fetch 都 404 ——
相对路径以「当前文档目录」为基准,没尾斜杠时浏览器把最后一段当文件名,
目录退回上一级,fetch('api/status') 打到 /p/api/status。
修:前缀模式且路径恰等于前缀时 301 到 /p/huawei/(保留查询串)。
**别名模式不做此事** —— 那类路径是上游真实语义,加斜杠会改坏它。
## 插件侧(huawei_smarthome)
- 前端 4 处根绝对路径(fetch('/api/status') 等)改为相对路径,
基准由 location.pathname 推导(BASE)。这是 Path 形态能成立的**前提** ——
否则请求会打到门户自己身上。
- plg.json 声明:host + path=/p/huawei + strip_path=true + auth=homeagent。
- SDK 升到 1.4.0,并用 hmapdev 1.4.0 重新打包(1.3.0 的 hmapdev 无
proxies 支持,会把声明**静默丢弃** —— 实测确认过,这是打包链路上
一个不报警的坑,值得记住)。
## 判据
+6 条:TestProxyPathAliasVsStrip(两态各自正确)、
TestProxyPathLongestPrefixWins(/p/app 不得劫持 /p/apple,
且长前缀胜出)、TestProxyStripPathRedirectsToTrailingSlash(尾斜杠,
含查询串保留 + 别名模式不得重定向)。
变异验证(4 条,均按预期打红后还原回绿):
- 删尾斜杠重定向 → 判红(还原了真实 bug 形态)
- 让别名模式也重定向 → 判红(设备网关语义被毁)
- 从 hmapdev schema 探测体删 StripPath → 判红(漂移检测有效)
- 删 SDK 里的「单一入口原则」字样 → 判红(契约不能只剩口头约定)
全量:35 包全绿。
346 lines
16 KiB
Go
346 lines
16 KiB
Go
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 读新插件都不会报错。
|
||
//
|
||
// 与 ToolDef / ChannelDef / ConfigDef 同族:SDK 定义声明契约,内核实现行为。
|
||
// 声明方式与其它能力一致 —— 在 Start() 里调 RegisterProxy(name, def),
|
||
// 或写进 plugin.json 的 proxies 字段(外部插件两种都支持)。
|
||
//
|
||
// 安全性:**不声明 = 不被反代**。声明本身就是能力声明,因此不需要在
|
||
// capabilities 里另外开一个开关——最小权限默认生效。
|
||
//
|
||
// # 单一入口原则(强制要求)
|
||
//
|
||
// **一个声明 = 一个入口**。被反代的插件必须让它的全部资源与接口都能从
|
||
// 该入口的一个基准路径出发访问到,不得依赖「入口之外的根路径」。
|
||
//
|
||
// 为什么强制:反代有两种挂载形态,而它们对「根路径」的处理截然不同——
|
||
//
|
||
// Host 形态(host):插件独占 <标签>.<基域名>,根路径就是插件的根。
|
||
// 根绝对路径(fetch('/api/x'))**天然正确**。
|
||
// Path 形态(path):插件挂在门户自身 host 的某个前缀下,根路径属于**门户**。
|
||
// 此时插件里的 fetch('/api/x') 会打到门户自己的 /api/x
|
||
// —— 静默错路由,页面能开但功能全坏。
|
||
//
|
||
// 于是「同一个插件必须同时支持两种形态」这条要求,等价于:
|
||
//
|
||
// **插件内部一律使用相对路径**(或基于 <base>/location 推导的路径),
|
||
// 绝不硬编码以 / 开头的绝对路径。
|
||
//
|
||
// 这样同一份前端在两种形态下都正确,插件作者也不必知道自己被挂在哪。
|
||
// 反代层据此可以:外部子域可用时给 Host 形态,子域不可用(证书/放行限制)
|
||
// 时给 Path 形态,**无需插件配合改动**。
|
||
//
|
||
// 自检(插件作者在本地就该做):把页面挂到 <门户>/<任意前缀>/ 下访问,
|
||
// 所有请求都必须仍然打到插件自己。
|
||
//
|
||
// 本项目实测案例:某插件前端写死 fetch('/api/status'),配在
|
||
// /p/huawei/ 下会打到门户的 /api/status(404 或返回门户数据);
|
||
// 改成相对路径后两种形态同时可用。
|
||
// ProxyDef 是一个服务的**反代声明体**。
|
||
//
|
||
// 与 ToolDef 同构:Name 同时出现在字段与 RegisterProxy 的第一个参数里
|
||
// (ToolDef 也是这么做的 —— 字段供 plugin.json 序列化,参数供运行期调用)。
|
||
// Name 只用于展示、日志与冲突提示,**不参与路由**(路由键是 Host 与 Path)。
|
||
type ProxyDef struct {
|
||
// Name 是同一插件内多条声明的唯一标识(如 "ui"、"api")。
|
||
// 运行期由 RegisterProxy 的第一个参数填入;声明式由 plugin.json 的
|
||
// name 键填入。省略时由 HomeAgent 兜底为 "service"。
|
||
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"`
|
||
|
||
// Path 是可选的**路径挂载前缀**(如 "/api/v1/device")。
|
||
//
|
||
// 为什么 Host 子域之外还需要它:子域形态依赖 DNS 解析,而 *.localhost
|
||
// 只有浏览器内置该特例(RFC 6761)—— 普通进程(设备客户端、固件、
|
||
// CLI)走系统解析器,实测解析不到,会以「no such host」失败。
|
||
// 路径形态挂在门户自身 host 下,**无任何 DNS 依赖**,是给非浏览器
|
||
// 客户端用的。
|
||
//
|
||
// 语义:请求路径**原样保留**(不做前缀剥除)——声明者按上游真实路径填写,
|
||
// 例如上游注册 /api/v1/device/ws,就声明 Path="/api/v1/device"。
|
||
// 这样设备客户端可以直接使用它已硬编码的路径,不需要知道反代的存在。
|
||
//
|
||
// 与 Host 形态的关系(见包注释的「单一入口原则」):声明的服务应当
|
||
// **同时**能被两种形态访问。因此 Path 形态下插件内部必须用相对路径,
|
||
// 否则它的前端会把请求打到门户自己身上。
|
||
//
|
||
// 留空 = 只提供子域形态(插件自带 UI 的常见情形:UI 与它自己的 API
|
||
// 同源,走子域天然正确)。
|
||
Path string `json:"path,omitempty"`
|
||
|
||
// StripPath 决定转发前是否**剥掉** Path 前缀。默认 false(原样保留)。
|
||
//
|
||
// 两种挂载语义真实不同,必须由声明者选,不能靠猜:
|
||
//
|
||
// false(别名模式):Path 就是上游真实路径的一部分。
|
||
// 请求 /api/v1/device/ws + Path="/api/v1/device"
|
||
// → 上游收到 /api/v1/device/ws(一模一样)。
|
||
// 适用:客户端**已硬编码**路径的机器接口(设备网关就是如此,
|
||
// 它按 /api/v1/device/ws 连接,不可能知道反代的存在)。
|
||
//
|
||
// true(前缀模式):Path 只是门户上的挂载点,上游不知道它。
|
||
// 请求 /p/myapp/api/status + Path="/p/myapp"
|
||
// → 上游收到 /api/status。
|
||
// 适用:自带 UI 的服务(前端用相对路径,被挂到哪里都对)。
|
||
//
|
||
// 为什么不能自动判定:同一个声明「Path=/api/v1/device」在两种语义下
|
||
// 都说得通,代理无从分辨 —— 猜错的结果是全部请求 404,且看起来像
|
||
// 上游故障。所以由声明者显式写清楚。
|
||
StripPath bool `json:"strip_path,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
|
||
}
|
||
|
||
// ValidateProxyDef 校验一条反代声明,返回人类可读的错误说明(合法时为空)。
|
||
//
|
||
// 为什么要在 SDK 里做校验:HomeAgent 加载插件时必须能明确拒绝坏声明并说明
|
||
// 原因(而不是静默忽略导致用户以为配好了);插件作者也需要在本地就能查出
|
||
// 拼错的 Target/Host。同一套规则两端共用。
|
||
func ValidateProxyDef(d ProxyDef) 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
|
||
}
|
||
if p := strings.TrimSpace(d.Path); p != "" {
|
||
if !strings.HasPrefix(p, "/") {
|
||
return "path 必须以 / 开头: " + d.Path
|
||
}
|
||
if strings.HasSuffix(p, "/") {
|
||
return "path 不应以 / 结尾(它是前缀,不是目录): " + d.Path
|
||
}
|
||
if strings.Contains(p, "..") || strings.ContainsAny(p, " \t\r\n\x00?#") {
|
||
return "path 含非法字符: " + d.Path
|
||
}
|
||
}
|
||
// 前缀模式必须给出可剥的前缀。
|
||
// 注意 "/" 不需要单独判:它是前缀又同时以 "/" 结尾,已被上面的
|
||
// 「不应以 / 结尾」规则挡掉(挂到门户根会覆盖整站的意图因此无法达成)。
|
||
if d.StripPath && strings.TrimSpace(d.Path) == "" {
|
||
return "strip_path=true 时必须给出 path(否则没有可剥的前缀)"
|
||
}
|
||
// 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 ""
|
||
}
|
||
|
||
// ProxyRegistrar 由内核注入(与 ToolRegistrar / InputChannelRegistrar 同族)。
|
||
// 插件不直接调它,用 RegisterProxy。
|
||
//
|
||
// 为什么需要运行期注册(明明有 plugin.json 自动发现):**内置插件**
|
||
// (编译进内核、没有独立插件目录与 plugin.json,如 remotedevice)扫不到;
|
||
// 而它们恰恰最需要被反代出去(设备网关就是内置的)。两种来源互补:
|
||
// - 外部插件 → plugin.json 的 proxies(静态,未启动也可见)
|
||
// - 内置插件 → RegisterProxy(运行期,随 Start 注册)
|
||
type ProxyRegistrar func(name string, def ProxyDef)
|
||
|
||
// SetProxyRegistrar 由内核注入。插件不直接调它(与 SetInputChannelRegistrar 同族)。
|
||
func (s *PluginSDK) SetProxyRegistrar(r ProxyRegistrar) {
|
||
if s == nil {
|
||
return
|
||
}
|
||
s.apiMu.Lock()
|
||
s.proxyReg = r
|
||
s.apiMu.Unlock()
|
||
}
|
||
|
||
// RegisterProxy 声明一个需要 HomeAgent 反代出去的服务。
|
||
//
|
||
// 与 RegisterTool / RegisterInputChannel / RegisterOutputChannel 同一风格:
|
||
// 显式给名字 + 声明体。名字用于展示、日志与冲突提示(不参与路由 —— 路由键是
|
||
// def.Host / def.Path)。
|
||
//
|
||
// 用法(通常在 Start 里调用):
|
||
//
|
||
// s.RegisterProxy("ui", sdk.ProxyDef{
|
||
// Host: "myapp", Target: "127.0.0.1:12100",
|
||
// })
|
||
//
|
||
// 声明立即生效(反代表在下一次请求时重建)。**不做去重**:同一 Host/Path
|
||
// 被两条声明占用时由反代层判定冲突并明确报错,而不是在这里静默吞掉 ——
|
||
// 插件作者需要看见冲突。
|
||
//
|
||
// 与 plugin.json 的 proxies 字段等价:写哪个都行,两者会合并(同名以本调用为准)。
|
||
func (s *PluginSDK) RegisterProxy(name string, def ProxyDef) {
|
||
if s == nil {
|
||
return
|
||
}
|
||
s.apiMu.RLock()
|
||
r := s.proxyReg
|
||
s.apiMu.RUnlock()
|
||
if r != nil {
|
||
r(name, def)
|
||
}
|
||
}
|