mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
feat(webui): 路径挂载的 strip_path 两态 + 尾斜杠重定向(修 /p/huawei 打不开数据)
用户要求用方案 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 包全绿。
This commit is contained in:
@ -10,8 +10,8 @@ import (
|
||||
|
||||
const PackageExt = ".hmap"
|
||||
|
||||
// SDKProxyDecl 是 SDK 反代声明在本包的别名(避免调用方两处 import)。
|
||||
type SDKProxyDecl = pubsdk.ProxyDecl
|
||||
// SDKProxyDef 是 SDK 反代声明在本包的别名(避免调用方两处 import)。
|
||||
type SDKProxyDef = pubsdk.ProxyDef
|
||||
|
||||
// PluginManifest 每个插件目录中的 plugin.json 元数据。
|
||||
type PluginManifest struct {
|
||||
@ -37,7 +37,7 @@ type PluginManifest struct {
|
||||
//
|
||||
// 字段解析忽略未知键(本仓无 DisallowUnknownFields),因此加这个字段
|
||||
// 对「旧内核读新插件」与「新内核读旧插件」都是无害的。
|
||||
Proxies []SDKProxyDecl `json:"proxies,omitempty"`
|
||||
Proxies []SDKProxyDef `json:"proxies,omitempty"`
|
||||
|
||||
// Capabilities 声明本插件需要的内核能力组(§3.8 权限梯度)。
|
||||
//
|
||||
|
||||
@ -89,7 +89,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
// 全部挡在门户鉴权之外——那正是"认证必须可声明"的原因。
|
||||
//
|
||||
// websocket=true:设备注册/命令下发走 WS 长连接。
|
||||
s.DeclareProxy(sdk.ProxyDecl{
|
||||
s.RegisterProxy("gateway", sdk.ProxyDef{
|
||||
Name: "gateway",
|
||||
Host: "devices",
|
||||
// Path 让**非浏览器客户端**也能用:*.localhost 只有浏览器内置解析
|
||||
|
||||
@ -50,14 +50,15 @@ import (
|
||||
|
||||
// ProxyRoute 是一条**已解析**的反代路由(声明 + 归属插件 + 校验结果)。
|
||||
type ProxyRoute struct {
|
||||
Plugin string // 声明该服务的插件名
|
||||
Name string // 声明内的服务标识(展示用,如 "ui")
|
||||
Host string // 子域标签(小写,已归一化)
|
||||
Path string // 可选的路径挂载前缀(非浏览器客户端用,无 DNS 依赖)
|
||||
Target string // 上游地址(原样,含可能的 scheme/路径前缀)
|
||||
WS bool // 是否允许 WebSocket 升级
|
||||
Auth string // 生效的鉴权模式(已归一化)
|
||||
Err string // 非空表示该条声明被拒绝及原因(不参与路由,仅展示)
|
||||
Plugin string // 声明该服务的插件名
|
||||
Name string // 声明内的服务标识(展示用,如 "ui")
|
||||
Host string // 子域标签(小写,已归一化)
|
||||
Path string // 可选的路径挂载前缀(非浏览器客户端用,无 DNS 依赖)
|
||||
StripPath bool // 转发前是否剥掉 Path 前缀(见 SDK 的说明:两种语义真实不同)
|
||||
Target string // 上游地址(原样,含可能的 scheme/路径前缀)
|
||||
WS bool // 是否允许 WebSocket 升级
|
||||
Auth string // 生效的鉴权模式(已归一化)
|
||||
Err string // 非空表示该条声明被拒绝及原因(不参与路由,仅展示)
|
||||
|
||||
upstream *url.URL
|
||||
reverse *httputil.ReverseProxy
|
||||
@ -191,16 +192,17 @@ func buildProxyTable(decls []proxyDecl, manualText string, settings sdk.Settings
|
||||
|
||||
for _, d := range decls {
|
||||
r := &ProxyRoute{
|
||||
Plugin: d.Plugin,
|
||||
Name: d.Name,
|
||||
Host: d.Host,
|
||||
Path: strings.TrimSpace(d.Path),
|
||||
Target: d.Target,
|
||||
WS: d.WebSocket,
|
||||
Auth: sdk.EffectiveProxyAuth(d.Auth),
|
||||
Plugin: d.Plugin,
|
||||
Name: d.Name,
|
||||
Host: d.Host,
|
||||
Path: strings.TrimSpace(d.Path),
|
||||
StripPath: d.StripPath,
|
||||
Target: d.Target,
|
||||
WS: d.WebSocket,
|
||||
Auth: sdk.EffectiveProxyAuth(d.Auth),
|
||||
}
|
||||
if msg := sdk.ValidateProxyDecl(sdk.ProxyDecl{
|
||||
Name: d.Name, Host: d.Host, Path: d.Path,
|
||||
if msg := sdk.ValidateProxyDef(sdk.ProxyDef{
|
||||
Host: d.Host, Path: d.Path, StripPath: d.StripPath,
|
||||
Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth,
|
||||
}); msg != "" {
|
||||
r.Err = msg
|
||||
@ -212,16 +214,17 @@ func buildProxyTable(decls []proxyDecl, manualText string, settings sdk.Settings
|
||||
|
||||
for _, d := range parseManualRoutes(manualText) {
|
||||
r := &ProxyRoute{
|
||||
Plugin: "manual",
|
||||
Name: d.Name,
|
||||
Host: d.Host,
|
||||
Path: strings.TrimSpace(d.Path),
|
||||
Target: d.Target,
|
||||
WS: d.WebSocket,
|
||||
Auth: sdk.EffectiveProxyAuth(d.Auth),
|
||||
Plugin: "manual",
|
||||
Name: d.Name,
|
||||
Host: d.Host,
|
||||
Path: strings.TrimSpace(d.Path),
|
||||
StripPath: d.StripPath,
|
||||
Target: d.Target,
|
||||
WS: d.WebSocket,
|
||||
Auth: sdk.EffectiveProxyAuth(d.Auth),
|
||||
}
|
||||
if msg := sdk.ValidateProxyDecl(sdk.ProxyDecl{
|
||||
Name: d.Name, Host: d.Host, Path: d.Path,
|
||||
if msg := sdk.ValidateProxyDef(sdk.ProxyDef{
|
||||
Host: d.Host, Path: d.Path, StripPath: d.StripPath,
|
||||
Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth,
|
||||
}); msg != "" {
|
||||
r.Err = msg
|
||||
@ -241,7 +244,13 @@ func buildProxyTable(decls []proxyDecl, manualText string, settings sdk.Settings
|
||||
continue
|
||||
}
|
||||
r.upstream = u
|
||||
r.reverse = newReverseProxy(u, r.Auth)
|
||||
// 前缀模式:Rewrite 时把 Path 前缀剥掉再交给上游。
|
||||
// 别名模式(原样保留):不需要额外处理 —— 客户端用的就是上游的真实路径。
|
||||
prefix := ""
|
||||
if r.StripPath {
|
||||
prefix = r.Path
|
||||
}
|
||||
r.reverse = newReverseProxy(u, r.Auth, prefix)
|
||||
log.Printf("[webui] 反代: %s.%s → %s (plugin=%s ws=%v auth=%s)",
|
||||
r.Host, base, r.Target, r.Plugin, r.WS, r.Auth)
|
||||
}
|
||||
@ -290,11 +299,28 @@ func parseUpstream(target string) (*url.URL, error) {
|
||||
// 泄给客户端。ReverseProxy 默认不跟随重定向,3xx 原样透传。
|
||||
// 3. **补齐转发头**:SetXForwarded 注入 X-Forwarded-For/Host/Proto,
|
||||
// 旧实现完全不注入,上游无法判断真实来源。
|
||||
func newReverseProxy(u *url.URL, auth string) *httputil.ReverseProxy {
|
||||
func newReverseProxy(u *url.URL, auth, stripPrefix string) *httputil.ReverseProxy {
|
||||
stripCredentials := auth == sdk.ProxyAuthHomeAgent
|
||||
rp := &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(u)
|
||||
// 前缀模式:剥掉门户上的挂载前缀,上游看到它自己的路径。
|
||||
// 别名模式(stripPrefix == ""):路径原样,见 SDK 的 Path 说明。
|
||||
if stripPrefix != "" {
|
||||
// 路径恰好等于前缀时(/p/myapp)余量是空串 —— 那是上游的
|
||||
// **根**,必须映射成 "/" 而不是保持原样(保持原样会把
|
||||
// /p/myapp 整个当路径转给上游,上游无从识别)。
|
||||
rest := strings.TrimPrefix(pr.In.URL.Path, stripPrefix)
|
||||
if rest == "" {
|
||||
rest = "/"
|
||||
}
|
||||
pr.Out.URL.Path = rest
|
||||
pr.Out.URL.RawPath = ""
|
||||
// SetURL 已按上游 base path 拼过一次,这里以 rest 为准重设。
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
pr.Out.URL.Path = strings.TrimSuffix(u.Path, "/") + rest
|
||||
}
|
||||
}
|
||||
pr.SetXForwarded()
|
||||
// 透传子域标签给上游(插件据此可感知自己被挂在哪个标签下)。
|
||||
pr.Out.Header.Set("X-HA-Proxy-Host", pr.In.Host)
|
||||
@ -376,7 +402,7 @@ func currentProxyTable() *proxyTable {
|
||||
decls = declProvider()
|
||||
}
|
||||
// 内置插件的运行期声明(无 plugin.json,扫目录发现不到)。
|
||||
for plugin, list := range sdk.BuiltinProxyDecls() {
|
||||
for plugin, list := range sdk.BuiltinProxyDefs() {
|
||||
for _, d := range list {
|
||||
host := d.Host
|
||||
if host == "" {
|
||||
@ -507,6 +533,8 @@ func hostnameOf(hostport string) string {
|
||||
func (t *proxyTable) matchProxyPath(p string) (*ProxyRoute, bool) {
|
||||
var best *ProxyRoute
|
||||
for prefix, r := range t.paths {
|
||||
// 边界必须卡在路径分隔符上:/p/huawei 不能匹配 /p/huaweix
|
||||
// (否则会劫持同前缀的其它管理页),但 /p/huawei 自身与其子路径都算。
|
||||
if p == prefix || strings.HasPrefix(p, prefix+"/") {
|
||||
if best == nil || len(prefix) > len(best.Path) {
|
||||
best = r
|
||||
@ -541,6 +569,20 @@ func (h *Handler) serveProxyHost(w http.ResponseWriter, r *http.Request) bool {
|
||||
// *.localhost)也能用:它们连门户地址本身即可,不需要知道反代的存在。
|
||||
// 路径原样保留 —— 客户端沿用它已有的路径。
|
||||
if rt, ok := t.matchProxyPath(r.URL.Path); ok && proxyHostLabel(r.Host, base) == "" {
|
||||
// 前缀模式下,路径**恰好等于挂载前缀**(/p/huawei,无尾斜杠)时
|
||||
// 必须重定向到 /p/huawei/。
|
||||
//
|
||||
// 原因在前端:相对路径的基准是「当前文档目录」。地址是 /p/huawei 时
|
||||
// 浏览器算出的目录是 /p/ —— 页面里的 fetch('api/status') 会打到
|
||||
// /p/api/status(404),看起来像插件坏了。补上尾斜杠后目录成为
|
||||
// /p/huawei/,相对路径立即正确。
|
||||
//
|
||||
// 别名模式(strip_path=false)**不能**这样做:那类客户端的路径是
|
||||
// 上游真实路径(/api/v1/device/online),加尾斜杠会改变语义。
|
||||
if rt.StripPath && rt.Path != "" && r.URL.Path == rt.Path {
|
||||
h.redirectToTrailingSlash(w, r, rt.Path)
|
||||
return true
|
||||
}
|
||||
if isWebSocketUpgrade(r) && !rt.WS {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": fmt.Sprintf("插件 %s 的服务 %s 未声明 websocket", rt.Plugin, rt.Name),
|
||||
@ -628,12 +670,13 @@ func isWebSocketUpgrade(r *http.Request) bool {
|
||||
|
||||
// proxyServiceEntry 是「服务入口」条目:给前端渲染选项卡用。
|
||||
type proxyServiceEntry struct {
|
||||
Plugin string `json:"plugin"`
|
||||
PluginZh string `json:"plugin_name"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path,omitempty"` // 路径挂载前缀(无 DNS 依赖的形态)
|
||||
URL string `json:"url"` // 子域形态(浏览器)
|
||||
Plugin string `json:"plugin"`
|
||||
PluginZh string `json:"plugin_name"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path,omitempty"` // 路径挂载前缀(无 DNS 依赖的形态)
|
||||
StripPath bool `json:"strip_path,omitempty"` // 该前缀是否被剥掉后转发
|
||||
URL string `json:"url"` // 子域形态(浏览器)
|
||||
// URLPortal 是门户同源形态:挂在门户自身 host 的路径下,**无 DNS 依赖**。
|
||||
// 非浏览器客户端(设备/固件/CLI)用系统解析器解析不了 *.localhost,用它。
|
||||
URLPortal string `json:"url_portal,omitempty"`
|
||||
@ -663,15 +706,16 @@ func (h *Handler) listProxyServices(scheme, hostPort, portalHost, domain string)
|
||||
out := make([]proxyServiceEntry, 0, len(t.ordered))
|
||||
for _, r := range t.ordered {
|
||||
e := proxyServiceEntry{
|
||||
Plugin: r.Plugin,
|
||||
Name: r.Name,
|
||||
Host: r.Host,
|
||||
Path: r.Path,
|
||||
Auth: r.Auth,
|
||||
WS: r.WS,
|
||||
Target: r.Target,
|
||||
OK: r.Err == "",
|
||||
Error: r.Err,
|
||||
Plugin: r.Plugin,
|
||||
Name: r.Name,
|
||||
Host: r.Host,
|
||||
Path: r.Path,
|
||||
StripPath: r.StripPath,
|
||||
Auth: r.Auth,
|
||||
WS: r.WS,
|
||||
Target: r.Target,
|
||||
OK: r.Err == "",
|
||||
Error: r.Err,
|
||||
}
|
||||
if m, ok := metas[r.Plugin]; ok {
|
||||
e.PluginZh = m.NameZh
|
||||
@ -704,6 +748,7 @@ type proxyDecl struct {
|
||||
Name string
|
||||
Host string
|
||||
Path string
|
||||
StripPath bool
|
||||
Target string
|
||||
WebSocket bool
|
||||
Auth string
|
||||
@ -753,6 +798,7 @@ func readPluginProxyDecls(pluginDir string) []proxyDecl {
|
||||
Name: sname,
|
||||
Host: strings.ToLower(host),
|
||||
Path: strings.TrimSpace(p.Path),
|
||||
StripPath: p.StripPath,
|
||||
Target: p.Target,
|
||||
WebSocket: p.WebSocket,
|
||||
Auth: p.Auth,
|
||||
@ -1034,3 +1080,21 @@ func (h *Handler) handleDeviceGatewayDiscovery(w http.ResponseWriter, r *http.Re
|
||||
out["target"] = route.Target
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// redirectToTrailingSlash 把 /p/app 重定向到 /p/app/(保留查询串)。
|
||||
//
|
||||
// 为什么需要:相对路径的解析基准是「当前文档所在目录」。没有尾斜杠时
|
||||
// 浏览器把最后一段当**文件名**,目录退回上一级 —— 页面里的
|
||||
// fetch('api/status') 于是打到 /p/api/status 而不是 /p/app/api/status。
|
||||
// 表现为:页面能打开,但所有数据加载失败(很容易误判成插件故障)。
|
||||
//
|
||||
// 用 301 而不是 302:这是稳定的规范形态,浏览器与中间层都可以长期缓存。
|
||||
func (h *Handler) redirectToTrailingSlash(w http.ResponseWriter, r *http.Request, prefix string) {
|
||||
target := prefix + "/"
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "?" + r.URL.RawQuery
|
||||
}
|
||||
// 保留门户可能挂载的额外前缀(如外层网关又套了一层 /ha)。
|
||||
// 这里以请求的真实路径为准做相对拼装,避免绝对路径丢失上下文。
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
@ -1442,3 +1442,202 @@ func TestBaseURLTolerant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 路径挂载的两种语义(必须由声明者选,不能猜)----
|
||||
//
|
||||
// 别名模式(strip_path=false,默认):Path 是上游真实路径的一部分。
|
||||
//
|
||||
// 设备网关就是这种 —— 客户端硬编码 /api/v1/device/ws,不可能知道反代。
|
||||
//
|
||||
// 前缀模式(strip_path=true):Path 只是门户上的挂载点,上游不知道它。
|
||||
//
|
||||
// 自带 UI 的服务是这种 —— 前端用相对路径,被挂到哪里都对。
|
||||
//
|
||||
// 猜错的结果是全部请求 404,且看起来像上游故障,所以必须显式声明。
|
||||
func TestProxyPathAliasVsStrip(t *testing.T) {
|
||||
var gotPath string
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
prev := declProvider
|
||||
SetProxyDeclProvider(func() []proxyDecl {
|
||||
return []proxyDecl{
|
||||
// 别名:原样保留(机器接口,客户端已硬编码路径)
|
||||
{Plugin: "gw", Name: "gateway", Host: "gw", Path: "/api/v1/device",
|
||||
Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
|
||||
// 前缀:剥掉后转发(自带 UI 的服务)
|
||||
{Plugin: "ui", Name: "ui", Host: "ui", Path: "/p/myapp", StripPath: true,
|
||||
Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
|
||||
}
|
||||
})
|
||||
manualProxyRoutes = ""
|
||||
InvalidateProxyRoutes()
|
||||
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
|
||||
|
||||
h := NewHandler(nil)
|
||||
call := func(p string) string {
|
||||
gotPath = ""
|
||||
rec := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", p, nil)
|
||||
r.Host = "127.0.0.1:8080"
|
||||
if !h.serveProxyHost(rec, r) {
|
||||
t.Fatalf("%s 未被路径挂载接住", p)
|
||||
}
|
||||
return gotPath
|
||||
}
|
||||
|
||||
// 别名模式:上游必须收到**一模一样**的路径
|
||||
if p := call("/api/v1/device/ws"); p != "/api/v1/device/ws" {
|
||||
t.Errorf("别名模式:上游收到 %q,期望原样 /api/v1/device/ws", p)
|
||||
}
|
||||
if p := call("/api/v1/device/online"); p != "/api/v1/device/online" {
|
||||
t.Errorf("别名模式:上游收到 %q", p)
|
||||
}
|
||||
|
||||
// 前缀模式:上游必须收到**剥掉前缀之后**的路径
|
||||
if p := call("/p/myapp/api/status"); p != "/api/status" {
|
||||
t.Errorf("前缀模式:上游收到 %q,期望 /api/status(前缀应被剥掉)", p)
|
||||
}
|
||||
if p := call("/p/myapp/"); p != "/" {
|
||||
t.Errorf("前缀模式根:上游收到 %q,期望 /", p)
|
||||
}
|
||||
// 无尾斜杠时**不再转发**,而是 301 到带尾斜杠的形态 ——
|
||||
// 否则浏览器算出的相对路径基准会退回上一级(见
|
||||
// TestProxyStripPathRedirectsToTrailingSlash)。这里断言它确实
|
||||
// 没有把 /p/myapp 当路径转给上游。
|
||||
gotPath = ""
|
||||
rec := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/p/myapp", nil)
|
||||
r.Host = "127.0.0.1:8080"
|
||||
h.serveProxyHost(rec, r)
|
||||
if rec.Code != http.StatusMovedPermanently || gotPath != "" {
|
||||
t.Errorf("前缀模式无尾斜杠应 301 且不转发,实际 code=%d upstream_path=%q", rec.Code, gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 路径前缀匹配必须**最长优先**,且边界卡在分隔符上。
|
||||
func TestProxyPathLongestPrefixWins(t *testing.T) {
|
||||
var gotPath string
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
prev := declProvider
|
||||
SetProxyDeclProvider(func() []proxyDecl {
|
||||
return []proxyDecl{
|
||||
{Plugin: "a", Name: "short", Host: "a", Path: "/p/app", StripPath: true,
|
||||
Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
|
||||
{Plugin: "b", Name: "long", Host: "b", Path: "/p/app/admin", StripPath: true,
|
||||
Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
|
||||
}
|
||||
})
|
||||
manualProxyRoutes = ""
|
||||
InvalidateProxyRoutes()
|
||||
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
|
||||
|
||||
h := NewHandler(nil)
|
||||
call := func(p string) string {
|
||||
gotPath = ""
|
||||
rec := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", p, nil)
|
||||
r.Host = "127.0.0.1:8080"
|
||||
h.serveProxyHost(rec, r)
|
||||
return gotPath
|
||||
}
|
||||
|
||||
// 更长前缀必须胜出(否则 /p/app/admin/x 会被 /p/app 抢走)
|
||||
if p := call("/p/app/admin/x"); p != "/x" {
|
||||
t.Errorf("最长前缀未生效:上游收到 %q,期望 /x(由 /p/app/admin 处理)", p)
|
||||
}
|
||||
if p := call("/p/app/other"); p != "/other" {
|
||||
t.Errorf("短前缀处理: %q,期望 /other", p)
|
||||
}
|
||||
|
||||
// 边界:/p/app 不得匹配 /p/apple(否则会劫持无关路径)
|
||||
rec := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/p/apple/pie", nil)
|
||||
r.Host = "127.0.0.1:8080"
|
||||
if h.serveProxyHost(rec, r) {
|
||||
t.Errorf("/p/apple 被 /p/app 前缀劫持了(边界必须卡在路径分隔符): gotPath=%q", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 前缀模式的尾斜杠(相对路径的基准)----
|
||||
//
|
||||
// 真实踩到的 bug:/p/huawei 能打开但页面里所有 fetch 都 404。
|
||||
// 原因是相对路径以「当前文档目录」为基准 —— 没有尾斜杠时浏览器把最后
|
||||
// 一段当文件名,目录退回上一级,fetch('api/status') 打到 /p/api/status。
|
||||
// 表现为「页面能开、数据全空」,极易误判成插件故障。
|
||||
func TestProxyStripPathRedirectsToTrailingSlash(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("upstream:" + r.URL.Path))
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
prev := declProvider
|
||||
SetProxyDeclProvider(func() []proxyDecl {
|
||||
return []proxyDecl{
|
||||
// 前缀模式:需要尾斜杠重定向
|
||||
{Plugin: "ui", Name: "ui", Host: "ui", Path: "/p/app", StripPath: true,
|
||||
Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
|
||||
// 别名模式:绝不能重定向(路径是上游真实语义)
|
||||
{Plugin: "gw", Name: "gw", Host: "gw", Path: "/api/v1/device",
|
||||
Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
|
||||
}
|
||||
})
|
||||
manualProxyRoutes = ""
|
||||
InvalidateProxyRoutes()
|
||||
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
|
||||
|
||||
h := NewHandler(nil)
|
||||
do := func(p string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", p, nil)
|
||||
r.Host = "127.0.0.1:8080"
|
||||
if !h.serveProxyHost(rec, r) {
|
||||
t.Fatalf("%s 未被接住", p)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
// 前缀模式 + 无尾斜杠 → 必须 301 到带尾斜杠
|
||||
rec := do("/p/app")
|
||||
if rec.Code != http.StatusMovedPermanently {
|
||||
t.Errorf("/p/app 应 301 到 /p/app/,实际 %d(body=%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/p/app/" {
|
||||
t.Errorf("Location = %q,期望 /p/app/", loc)
|
||||
}
|
||||
|
||||
// 查询串必须保留
|
||||
rec = do("/p/app?a=1&b=2")
|
||||
if loc := rec.Header().Get("Location"); loc != "/p/app/?a=1&b=2" {
|
||||
t.Errorf("带查询串的 Location = %q,期望 /p/app/?a=1&b=2", loc)
|
||||
}
|
||||
|
||||
// 有尾斜杠 → 正常转发到上游根
|
||||
rec = do("/p/app/")
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != "upstream:/" {
|
||||
t.Errorf("/p/app/ 应转发到上游 /,实际 %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 子路径不受影响(不重定向)
|
||||
rec = do("/p/app/api/status")
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != "upstream:/api/status" {
|
||||
t.Errorf("/p/app/api/status 应转发到 /api/status,实际 %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// ★ 别名模式绝不能重定向:/api/v1/device 是上游真实路径,加斜杠会毁掉语义
|
||||
rec = do("/api/v1/device")
|
||||
if rec.Code == http.StatusMovedPermanently {
|
||||
t.Errorf("别名模式的 /api/v1/device 被重定向了 —— 那类客户端的路径是上游真实语义,不能改")
|
||||
}
|
||||
if rec.Body.String() != "upstream:/api/v1/device" {
|
||||
t.Errorf("别名模式应原样转发 /api/v1/device,实际 %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@ -322,7 +322,7 @@ func New(name string, cfg SDKConfig) *PluginSDK {
|
||||
base := pubsdk.New(name, cfg.Settings, cfg.RegTool, cfg.RegStage, cfg.RegAPI, cfg.RegOutput)
|
||||
// 反代声明收集:内置插件(无 plugin.json)在 Start 里用 DeclareProxy
|
||||
// 声明自己的服务,落到本包的登记表;外部插件走 plugin.json 自动发现。
|
||||
base.SetProxyDeclarer(func(d pubsdk.ProxyDecl) { DeclareBuiltinProxy(name, d) })
|
||||
base.SetProxyRegistrar(func(svc string, d pubsdk.ProxyDef) { RegisterBuiltinProxy(name, svc, d) })
|
||||
if cfg.IOManager != nil {
|
||||
base.SetIOInjector(ioAdapter{iom: cfg.IOManager})
|
||||
}
|
||||
|
||||
@ -1,3 +1,13 @@
|
||||
// Package sdk —— 反代声明的内核侧登记表。
|
||||
//
|
||||
// 契约定义在公开 SDK(pubsdk.ProxyDef)。本文件只做两件事:
|
||||
//
|
||||
// 1. 把公开类型重新导出,让内核侧调用方只需 import 本包。
|
||||
// 2. 维护**内置插件**的声明表(它们没有 plugin.json,扫目录发现不了)。
|
||||
//
|
||||
// 外部插件的声明由反代层直接读 plugin.json 得到,不经过这里 —— 那份声明
|
||||
// 是静态的、插件没启动也可见,而内置插件的声明只能运行期拿到。两种来源在
|
||||
// 反代层合并(见 webui.buildProxyTable)。
|
||||
package sdk
|
||||
|
||||
import (
|
||||
@ -7,101 +17,93 @@ import (
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// 反代声明在 **公开 SDK** 里定义(`pubsdk`),内核侧只是别名转发。
|
||||
//
|
||||
// 为什么放公开 SDK 而不是内核实现在:声明是**插件作者直接书写的契约**
|
||||
// (plugin.json 的 proxies 字段),必须与 SDK 文档、hmapdev 工具链用同一套
|
||||
// 定义与校验,否则插件作者本地通过、内核拒绝,或反之。
|
||||
//
|
||||
// 这与 ConfigDef 等信息完全同构——公开面定义契约,内核面实现行为。
|
||||
// ProxyDef 是一条反代声明(见 pubsdk.ProxyDef 的完整文档,含「单一入口原则」)。
|
||||
// 与 ConfigDef / ChannelDef / ToolDef 同族:SDK 定契约,内核实现行为。
|
||||
type ProxyDef = pubsdk.ProxyDef
|
||||
|
||||
// ProxyDecl 是一条反代声明(见 pubsdk.ProxyDecl 的完整文档)。
|
||||
type ProxyDecl = pubsdk.ProxyDecl
|
||||
|
||||
// 生效的鉴权模式取值。
|
||||
// 鉴权与 Host 规范化的常量/函数转发(调用方不必两处 import)。
|
||||
const (
|
||||
// ProxyAuthHomeAgent:由 HomeAgent 统一保护(门户会话或 X-API-Key)。
|
||||
ProxyAuthHomeAgent = pubsdk.ProxyAuthHomeAgent
|
||||
// ProxyAuthNone:不经 HomeAgent 鉴权,信任上游自身鉴权。
|
||||
ProxyAuthNone = pubsdk.ProxyAuthNone
|
||||
ProxyAuthNone = pubsdk.ProxyAuthNone
|
||||
)
|
||||
|
||||
// ValidProxyAuth 校验鉴权模式取值(空串合法,等价 ProxyAuthHomeAgent)。
|
||||
func ValidProxyAuth(auth string) bool { return pubsdk.ValidProxyAuth(auth) }
|
||||
// ValidateProxyDef 校验一条声明,返回人类可读的错误(合法时为空)。
|
||||
func ValidateProxyDef(d ProxyDef) string { return pubsdk.ValidateProxyDef(d) }
|
||||
|
||||
// EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHomeAgent)。
|
||||
// EffectiveProxyAuth 返回生效的鉴权模式(空串按默认 homeagent 处理)。
|
||||
func EffectiveProxyAuth(auth string) string { return pubsdk.EffectiveProxyAuth(auth) }
|
||||
|
||||
// ValidProxyHostLabel 校验子域标签是否合法(DNS label 规则)。
|
||||
// ValidProxyAuth 判断鉴权取值是否合法。
|
||||
func ValidProxyAuth(auth string) bool { return pubsdk.ValidProxyAuth(auth) }
|
||||
|
||||
// NormalizeProxyHost 由插件名推导默认的 Host 标签。
|
||||
func NormalizeProxyHost(plugin string) string { return pubsdk.NormalizeProxyHost(plugin) }
|
||||
|
||||
// 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)正是内置的,且最需要被反代出去。两种来源互补。
|
||||
// 内置插件(编译进内核、无 plugin.json)的反代声明表。
|
||||
//
|
||||
// 版本号 builtinProxyVer 每次变更自增。反代层据此判断缓存的路由表是否过期 ——
|
||||
// 比每次请求都重新聚合一遍便宜得多。
|
||||
var (
|
||||
builtinProxyMu sync.RWMutex
|
||||
builtinProxyDecls = map[string][]ProxyDecl{}
|
||||
builtinProxyVer int64
|
||||
builtinProxyMu sync.RWMutex
|
||||
builtinProxyVer int64
|
||||
builtinProxyDefs = map[string][]ProxyDef{}
|
||||
)
|
||||
|
||||
// DeclareBuiltinProxy 登记一个内置插件的服务声明(由 DeclareProxy 转发)。
|
||||
func DeclareBuiltinProxy(plugin string, d ProxyDecl) {
|
||||
// RegisterBuiltinProxy 登记一个内置插件的服务声明(由 RegisterProxy 转发)。
|
||||
//
|
||||
// ProxyDef.Name 由调用方保证非空(RegisterProxy 会在缺失时兜底为 "service")。
|
||||
func RegisterBuiltinProxy(plugin, name string, d ProxyDef) {
|
||||
if plugin == "" || strings.TrimSpace(d.Target) == "" {
|
||||
return
|
||||
}
|
||||
builtinProxyMu.Lock()
|
||||
defer builtinProxyMu.Unlock()
|
||||
// 同一插件同一声明名重复登记(如自动重启后再次 Start)视为刷新,不重复累积。
|
||||
name := d.Name
|
||||
if name == "" {
|
||||
name = "service"
|
||||
if d.Name == "" {
|
||||
d.Name = name
|
||||
}
|
||||
list := builtinProxyDecls[plugin]
|
||||
if d.Name == "" {
|
||||
d.Name = "service"
|
||||
}
|
||||
builtinProxyMu.Lock()
|
||||
defer builtinProxyMu.Unlock()
|
||||
// 同名重复登记(如自动重启后再次 Start)视为刷新,不重复累积。
|
||||
list := builtinProxyDefs[plugin]
|
||||
for i := range list {
|
||||
if list[i].Name == name {
|
||||
if list[i].Name == d.Name {
|
||||
list[i] = d
|
||||
builtinProxyVer++
|
||||
return
|
||||
}
|
||||
}
|
||||
builtinProxyDecls[plugin] = append(list, d)
|
||||
builtinProxyDefs[plugin] = append(list, d)
|
||||
builtinProxyVer++
|
||||
}
|
||||
|
||||
// ClearBuiltinProxyDecls 清除某插件的声明(插件停止/卸载时调用)。
|
||||
func ClearBuiltinProxyDecls(plugin string) {
|
||||
// ClearBuiltinProxyDefs 清除某插件的声明(插件停止/卸载时调用)。
|
||||
func ClearBuiltinProxyDefs(plugin string) {
|
||||
builtinProxyMu.Lock()
|
||||
defer builtinProxyMu.Unlock()
|
||||
if _, ok := builtinProxyDecls[plugin]; ok {
|
||||
delete(builtinProxyDecls, plugin)
|
||||
if _, ok := builtinProxyDefs[plugin]; ok {
|
||||
delete(builtinProxyDefs, plugin)
|
||||
builtinProxyVer++
|
||||
}
|
||||
}
|
||||
|
||||
// BuiltinProxyDecls 返回内置插件声明的快照(plugin → decls)。
|
||||
func BuiltinProxyDecls() map[string][]ProxyDecl {
|
||||
// BuiltinProxyDefs 返回内置声明的快照(plugin → defs)。
|
||||
func BuiltinProxyDefs() map[string][]ProxyDef {
|
||||
builtinProxyMu.RLock()
|
||||
defer builtinProxyMu.RUnlock()
|
||||
out := make(map[string][]ProxyDecl, len(builtinProxyDecls))
|
||||
for k, v := range builtinProxyDecls {
|
||||
cp := make([]ProxyDecl, len(v))
|
||||
out := make(map[string][]ProxyDef, len(builtinProxyDefs))
|
||||
for k, v := range builtinProxyDefs {
|
||||
cp := make([]ProxyDef, len(v))
|
||||
copy(cp, v)
|
||||
out[k] = cp
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuiltinProxyVersion 是声明表的版本号。调用方(webui 反代层)据它判断
|
||||
// 缓存的路由表是否过期——比每次请求重新聚合一遍便宜得多。
|
||||
// BuiltinProxyVersion 是声明表的版本号,供反代层判断缓存是否过期。
|
||||
func BuiltinProxyVersion() int64 {
|
||||
builtinProxyMu.RLock()
|
||||
defer builtinProxyMu.RUnlock()
|
||||
|
||||
@ -4,18 +4,18 @@ import "testing"
|
||||
|
||||
// 内置插件的运行期声明必须真的被登记、可枚举、可清除——
|
||||
// remotedevice(内置、无 plugin.json)就靠这条通道。
|
||||
func TestBuiltinProxyDeclRegistry(t *testing.T) {
|
||||
func TestBuiltinProxyDefRegistry(t *testing.T) {
|
||||
const p = "test_builtin_proxy"
|
||||
ClearBuiltinProxyDecls(p)
|
||||
defer ClearBuiltinProxyDecls(p)
|
||||
ClearBuiltinProxyDefs(p)
|
||||
defer ClearBuiltinProxyDefs(p)
|
||||
|
||||
before := BuiltinProxyVersion()
|
||||
DeclareBuiltinProxy(p, ProxyDecl{Name: "gw", Host: "devices", Target: "127.0.0.1:9890", WebSocket: true, Auth: ProxyAuthNone})
|
||||
RegisterBuiltinProxy(p, "gw", ProxyDef{Name: "gw", Host: "devices", Target: "127.0.0.1:9890", WebSocket: true, Auth: ProxyAuthNone})
|
||||
if BuiltinProxyVersion() == before {
|
||||
t.Error("登记后版本号应递增(反代层靠它判断缓存失效)")
|
||||
}
|
||||
|
||||
got := BuiltinProxyDecls()
|
||||
got := BuiltinProxyDefs()
|
||||
list := got[p]
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("登记了 %d 条,期望 1: %+v", len(list), got)
|
||||
@ -25,31 +25,39 @@ func TestBuiltinProxyDeclRegistry(t *testing.T) {
|
||||
}
|
||||
|
||||
// 重复登记同名(如自动重启后再次 Start)应为刷新而非累积
|
||||
DeclareBuiltinProxy(p, ProxyDecl{Name: "gw", Host: "devices", Target: "127.0.0.1:9890", WebSocket: true, Auth: ProxyAuthNone})
|
||||
if l := BuiltinProxyDecls()[p]; len(l) != 1 {
|
||||
RegisterBuiltinProxy(p, "gw", ProxyDef{Name: "gw", Host: "devices", Target: "127.0.0.1:9890", WebSocket: true, Auth: ProxyAuthNone})
|
||||
if l := BuiltinProxyDefs()[p]; len(l) != 1 {
|
||||
t.Errorf("重复登记应为刷新,实际累积成 %d 条", len(l))
|
||||
}
|
||||
|
||||
// 空 target 必须被拒(不声明的默认就是不被反代,空声明更不该登记)
|
||||
DeclareBuiltinProxy(p, ProxyDecl{Name: "bad", Target: " "})
|
||||
if l := BuiltinProxyDecls()[p]; len(l) != 1 {
|
||||
RegisterBuiltinProxy(p, "bad", ProxyDef{Name: "bad", Target: " "})
|
||||
if l := BuiltinProxyDefs()[p]; len(l) != 1 {
|
||||
t.Errorf("空 target 不应被登记,实际 %d 条", len(l))
|
||||
}
|
||||
|
||||
ClearBuiltinProxyDecls(p)
|
||||
if _, ok := BuiltinProxyDecls()[p]; ok {
|
||||
ClearBuiltinProxyDefs(p)
|
||||
if _, ok := BuiltinProxyDefs()[p]; ok {
|
||||
t.Error("清除后不应还有该插件的声明")
|
||||
}
|
||||
}
|
||||
|
||||
// DeclareProxy 必须把声明转发给内核注入的收集回调。
|
||||
func TestDeclareProxyForwards(t *testing.T) {
|
||||
var got []ProxyDecl
|
||||
// RegisterProxy 必须把声明转发给内核注入的注册回调,且把 name 落进 def
|
||||
// (与 RegisterTool 的风格一致:name 同时来自参数与 def.Name)。
|
||||
func TestRegisterProxyForwards(t *testing.T) {
|
||||
var gotName string
|
||||
var got []ProxyDef
|
||||
ps := New("demo", SDKConfig{})
|
||||
ps.SetProxyDeclarer(func(d ProxyDecl) { got = append(got, d) })
|
||||
ps.SetProxyRegistrar(func(name string, d ProxyDef) {
|
||||
gotName = name
|
||||
got = append(got, d)
|
||||
})
|
||||
|
||||
ps.DeclareProxy(ProxyDecl{Name: "ui", Host: "demo", Target: "127.0.0.1:12100", WebSocket: false, Auth: ProxyAuthNone})
|
||||
ps.RegisterProxy("ui", ProxyDef{Name: "ui", Host: "demo", Target: "127.0.0.1:12100", Auth: ProxyAuthNone})
|
||||
if len(got) != 1 || got[0].Host != "demo" || got[0].Auth != ProxyAuthNone {
|
||||
t.Fatalf("声明未转发到收集回调: %+v", got)
|
||||
t.Fatalf("声明未转发到注册回调: %+v", got)
|
||||
}
|
||||
if gotName != "ui" {
|
||||
t.Errorf("name 参数未透传: %q", gotName)
|
||||
}
|
||||
}
|
||||
|
||||
2
third_party/homeagent-sdk/sdk/plugin.go
vendored
2
third_party/homeagent-sdk/sdk/plugin.go
vendored
@ -356,7 +356,7 @@ type PluginSDK struct {
|
||||
// proxyDecl 是反代声明的收集回调(内置插件经 DeclareProxy 声明服务)。
|
||||
// 与上面的 API 字段同受 apiMu 保护——写方是内核注入,读方是插件 Start
|
||||
// 起的 goroutine。
|
||||
proxyDecl ProxyDeclarer
|
||||
proxyReg ProxyRegistrar
|
||||
|
||||
// apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。
|
||||
//
|
||||
|
||||
131
third_party/homeagent-sdk/sdk/proxy.go
vendored
131
third_party/homeagent-sdk/sdk/proxy.go
vendored
@ -21,18 +21,51 @@ import (
|
||||
// 3. 旧内核无害:manifest 解析忽略未知字段,未支持该能力的 HomeAgent 读旧
|
||||
// 插件、或旧 HomeAgent 读新插件都不会报错。
|
||||
//
|
||||
// 与 ToolDef / ChannelDef / ConfigDef 同族:SDK 定义声明契约,内核实现行为。
|
||||
// 声明方式与其它能力一致 —— 在 Start() 里调 RegisterProxy(name, def),
|
||||
// 或写进 plugin.json 的 proxies 字段(外部插件两种都支持)。
|
||||
//
|
||||
// 安全性:**不声明 = 不被反代**。声明本身就是能力声明,因此不需要在
|
||||
// capabilities 里另外开一个开关——最小权限默认生效。
|
||||
//
|
||||
// 反代路径:HomeAgent 按 Host 路由(子域名标签 → Target),而非路径前缀。
|
||||
// 理由:插件前端普遍使用根绝对路径(`fetch('/api/status')`),放在路径前缀
|
||||
// 下会被劫持到 HomeAgent 自己的路由上;Host 路由下根路径天然正确,
|
||||
// 插件前端**零改动**。这也让「只穿透一个端口」成立:同一端口按 Host 分发。
|
||||
type ProxyDecl struct {
|
||||
// # 单一入口原则(强制要求)
|
||||
//
|
||||
// **一个声明 = 一个入口**。被反代的插件必须让它的全部资源与接口都能从
|
||||
// 该入口的一个基准路径出发访问到,不得依赖「入口之外的根路径」。
|
||||
//
|
||||
// 为什么强制:反代有两种挂载形态,而它们对「根路径」的处理截然不同——
|
||||
//
|
||||
// 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")。
|
||||
// 省略时由 HomeAgent 按声明顺序补 "default"/"ui"/"api"... 仅用于展示与日志。
|
||||
// 运行期由 RegisterProxy 的第一个参数填入;声明式由 plugin.json 的
|
||||
// name 键填入。省略时由 HomeAgent 兜底为 "service"。
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Host 是**子域名标签**(不含基域名),如 "huawei" 对应 huawei.<基域名>。
|
||||
//
|
||||
// 约束:仅小写字母、数字与连字符,不以连字符开头/结尾,长度 ≤ 63
|
||||
@ -69,10 +102,34 @@ type ProxyDecl struct {
|
||||
// 例如上游注册 /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(默认安全)。
|
||||
//
|
||||
@ -164,12 +221,12 @@ func NormalizeProxyHost(pluginName string) string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ValidateProxyDecl 校验一条反代声明,返回人类可读的错误说明(合法时为空)。
|
||||
// ValidateProxyDef 校验一条反代声明,返回人类可读的错误说明(合法时为空)。
|
||||
//
|
||||
// 为什么要在 SDK 里做校验:HomeAgent 加载插件时必须能明确拒绝坏声明并说明
|
||||
// 原因(而不是静默忽略导致用户以为配好了);插件作者也需要在本地就能查出
|
||||
// 拼错的 Target/Host。同一套规则两端共用。
|
||||
func ValidateProxyDecl(d ProxyDecl) string {
|
||||
func ValidateProxyDef(d ProxyDef) string {
|
||||
if strings.TrimSpace(d.Target) == "" {
|
||||
return "target 为空:必须给出上游地址(如 127.0.0.1:12100 或 http://127.0.0.1:12100)"
|
||||
}
|
||||
@ -190,6 +247,12 @@ func ValidateProxyDecl(d ProxyDecl) string {
|
||||
return "path 含非法字符: " + d.Path
|
||||
}
|
||||
}
|
||||
// 前缀模式必须给出可剥的前缀。
|
||||
// 注意 "/" 不需要单独判:它是前缀又同时以 "/" 结尾,已被上面的
|
||||
// 「不应以 / 结尾」规则挡掉(挂到门户根会覆盖整站的意图因此无法达成)。
|
||||
if d.StripPath && strings.TrimSpace(d.Path) == "" {
|
||||
return "strip_path=true 时必须给出 path(否则没有可剥的前缀)"
|
||||
}
|
||||
// Target 的 host:port 部分必须可解析;路径前缀允许保留。
|
||||
//
|
||||
// 规则(刻意从严,因为地址写错是最常见的声明错误,而错误的反代会把
|
||||
@ -232,45 +295,51 @@ func ValidateProxyDecl(d ProxyDecl) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ProxyDeclarer 是内核注入的「收集反代声明」回调。
|
||||
// ProxyRegistrar 由内核注入(与 ToolRegistrar / InputChannelRegistrar 同族)。
|
||||
// 插件不直接调它,用 RegisterProxy。
|
||||
//
|
||||
// 为什么需要运行期通道(明明主要走 plugin.json 自动发现):**内置插件**
|
||||
// (编译进内核、没有独立插件目录与 plugin.json,如 remotedevice)无法靠
|
||||
// 扫目录发现自己的服务;而它们恰恰最需要被反代出去(设备网关就是内置的)。
|
||||
// 两种来源互补:
|
||||
// - 外部插件 → plugin.json 的 proxies(静态、未启动也可见)
|
||||
// - 内置插件 → DeclareProxy(运行期,随 Start 注册)
|
||||
type ProxyDeclarer func(decl ProxyDecl)
|
||||
// 为什么需要运行期注册(明明有 plugin.json 自动发现):**内置插件**
|
||||
// (编译进内核、没有独立插件目录与 plugin.json,如 remotedevice)扫不到;
|
||||
// 而它们恰恰最需要被反代出去(设备网关就是内置的)。两种来源互补:
|
||||
// - 外部插件 → plugin.json 的 proxies(静态,未启动也可见)
|
||||
// - 内置插件 → RegisterProxy(运行期,随 Start 注册)
|
||||
type ProxyRegistrar func(name string, def ProxyDef)
|
||||
|
||||
// SetProxyDeclarer 由内核注入收集回调。插件不直接调它。
|
||||
func (s *PluginSDK) SetProxyDeclarer(d ProxyDeclarer) {
|
||||
// SetProxyRegistrar 由内核注入。插件不直接调它(与 SetInputChannelRegistrar 同族)。
|
||||
func (s *PluginSDK) SetProxyRegistrar(r ProxyRegistrar) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.apiMu.Lock()
|
||||
s.proxyDecl = d
|
||||
s.proxyReg = r
|
||||
s.apiMu.Unlock()
|
||||
}
|
||||
|
||||
// DeclareProxy 声明本插件的一个服务需要 HomeAgent 反代出去。
|
||||
// RegisterProxy 声明一个需要 HomeAgent 反代出去的服务。
|
||||
//
|
||||
// 与 RegisterTool / RegisterInputChannel / RegisterOutputChannel 同一风格:
|
||||
// 显式给名字 + 声明体。名字用于展示、日志与冲突提示(不参与路由 —— 路由键是
|
||||
// def.Host / def.Path)。
|
||||
//
|
||||
// 用法(通常在 Start 里调用):
|
||||
//
|
||||
// s.DeclareProxy(sdk.ProxyDecl{
|
||||
// Name: "ui", Host: "myapp", Target: "127.0.0.1:12100",
|
||||
// s.RegisterProxy("ui", sdk.ProxyDef{
|
||||
// Host: "myapp", Target: "127.0.0.1:12100",
|
||||
// })
|
||||
//
|
||||
// 声明立即生效(反代表会在下一次请求时重建)。声明**不做去重**:同一 Host
|
||||
// 被两条声明占用时由反代层判定冲突并明确报错,而不是这里静默吞掉——
|
||||
// 声明立即生效(反代表在下一次请求时重建)。**不做去重**:同一 Host/Path
|
||||
// 被两条声明占用时由反代层判定冲突并明确报错,而不是在这里静默吞掉 ——
|
||||
// 插件作者需要看见冲突。
|
||||
func (s *PluginSDK) DeclareProxy(decl ProxyDecl) {
|
||||
//
|
||||
// 与 plugin.json 的 proxies 字段等价:写哪个都行,两者会合并(同名以本调用为准)。
|
||||
func (s *PluginSDK) RegisterProxy(name string, def ProxyDef) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.apiMu.Lock()
|
||||
d := s.proxyDecl
|
||||
s.apiMu.Unlock()
|
||||
if d != nil {
|
||||
d(decl)
|
||||
s.apiMu.RLock()
|
||||
r := s.proxyReg
|
||||
s.apiMu.RUnlock()
|
||||
if r != nil {
|
||||
r(name, def)
|
||||
}
|
||||
}
|
||||
|
||||
72
third_party/homeagent-sdk/sdk/proxy_test.go
vendored
72
third_party/homeagent-sdk/sdk/proxy_test.go
vendored
@ -1,6 +1,10 @@
|
||||
package sdk
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProxyAuthDefaultsToHomeAgent(t *testing.T) {
|
||||
// 空串必须归一化为「HomeAgent 统一保护」——这是安全默认。
|
||||
@ -76,8 +80,8 @@ func TestNormalizeProxyHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProxyDecl(t *testing.T) {
|
||||
valid := []ProxyDecl{
|
||||
func TestValidateProxyDef(t *testing.T) {
|
||||
valid := []ProxyDef{
|
||||
{Target: "127.0.0.1:12100"},
|
||||
{Target: "http://127.0.0.1:12100"},
|
||||
{Target: "127.0.0.1:12100", Host: "huawei"},
|
||||
@ -87,25 +91,73 @@ func TestValidateProxyDecl(t *testing.T) {
|
||||
{Target: "https://example.com", Host: "ext"}, // 远程上游也允许(由 auth 决定安全性)
|
||||
}
|
||||
for _, d := range valid {
|
||||
if msg := ValidateProxyDecl(d); msg != "" {
|
||||
if msg := ValidateProxyDef(d); msg != "" {
|
||||
t.Errorf("%+v 应合法,却报: %s", d, msg)
|
||||
}
|
||||
}
|
||||
|
||||
bad := []ProxyDecl{
|
||||
{}, // 无 target
|
||||
{Target: " "}, // 空白 target
|
||||
bad := []ProxyDef{
|
||||
{}, // 无 target
|
||||
{Target: " "}, // 空白 target
|
||||
{Target: "127.0.0.1:12100", Auth: "yes"}, // auth 非法
|
||||
{Target: "127.0.0.1:12100", Host: "a_b"}, // host 非法
|
||||
{Target: "127.0.0.1:12100", Host: "-x"},
|
||||
{Target: "127.0.0.1:12100", Host: "X"},
|
||||
{Target: "://12100"}, // 无主机
|
||||
{Target: "http:///path"}, // 无主机
|
||||
{Target: "://12100"}, // 无主机
|
||||
{Target: "http:///path"}, // 无主机
|
||||
{Target: "127.0.0.1:notaport"}, // 端口非数字
|
||||
}
|
||||
for _, d := range bad {
|
||||
if msg := ValidateProxyDecl(d); msg == "" {
|
||||
if msg := ValidateProxyDef(d); msg == "" {
|
||||
t.Errorf("%+v 应被拒绝,却通过了", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 单一入口原则 ----
|
||||
|
||||
// 被反代的插件必须能同时适配 Host 形态与 Path 形态。这两条判据把
|
||||
// 「插件内部不得用根绝对路径」这条契约钉在**可执行**的层面:
|
||||
// 声明合法不代表它的资源能被两种形态访问到 —— 后者取决于插件前端的写法,
|
||||
// 而 SDK 只能把要求写清楚并给出校验工具。
|
||||
func TestSingleEntryPrincipleDocumented(t *testing.T) {
|
||||
// Path 形态下插件前端必须用相对路径,否则请求会打到门户自己。
|
||||
// 这是**文档级约定**,只能靠 review 与这份判据共同保证:
|
||||
// 判据确保 SDK 里确实写明了这条要求(防止后来者删掉注释)。
|
||||
src, err := os.ReadFile("proxy.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"单一入口原则",
|
||||
"相对路径",
|
||||
"根绝对路径",
|
||||
} {
|
||||
if !strings.Contains(string(src), want) {
|
||||
t.Errorf("SDK 文档缺少「%s」—— 单一入口原则是反代的硬要求,不能只存在于口头约定里", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// strip_path 的两种语义必须由声明者显式选,且非法组合要被挡住。
|
||||
func TestStripPathValidation(t *testing.T) {
|
||||
// 合法:两种模式
|
||||
for _, d := range []ProxyDef{
|
||||
{Target: "127.0.0.1:1", Path: "/p/app", StripPath: true},
|
||||
{Target: "127.0.0.1:1", Path: "/api/v1/device", StripPath: false},
|
||||
} {
|
||||
if msg := ValidateProxyDef(d); msg != "" {
|
||||
t.Errorf("应合法却被拒: %+v → %s", d, msg)
|
||||
}
|
||||
}
|
||||
// 非法:strip_path 但没有 path(没有可剥的前缀)
|
||||
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", StripPath: true}); msg == "" {
|
||||
t.Error("strip_path=true 而无 path 应被拒(没有可剥的前缀)")
|
||||
}
|
||||
// 非法:前缀模式挂到根会吞掉整个门户。
|
||||
// 实际由「不应以 / 结尾」规则挡下("/" 同时是前缀又以 / 结尾),
|
||||
// 这里断言的是**行为**:这种声明无论如何都不能通过。
|
||||
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", Path: "/", StripPath: true}); msg == "" {
|
||||
t.Error("path=\"/\" + strip_path 应被拒(会覆盖整个门户)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user