diff --git a/internal/plugin/manifest.go b/internal/plugin/manifest.go index 521b627..8f57ed1 100644 --- a/internal/plugin/manifest.go +++ b/internal/plugin/manifest.go @@ -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 权限梯度)。 // diff --git a/internal/plugins/remotedevice/plugin.go b/internal/plugins/remotedevice/plugin.go index 81270c7..2501ea4 100644 --- a/internal/plugins/remotedevice/plugin.go +++ b/internal/plugins/remotedevice/plugin.go @@ -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 只有浏览器内置解析 diff --git a/internal/plugins/webui/proxy.go b/internal/plugins/webui/proxy.go index 3122f5b..3e593e8 100644 --- a/internal/plugins/webui/proxy.go +++ b/internal/plugins/webui/proxy.go @@ -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) +} diff --git a/internal/plugins/webui/proxy_test.go b/internal/plugins/webui/proxy_test.go index 7c43995..6630e30 100644 --- a/internal/plugins/webui/proxy_test.go +++ b/internal/plugins/webui/proxy_test.go @@ -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()) + } +} diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index 3fd01e7..0acfd7a 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -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}) } diff --git a/internal/sdk/proxy.go b/internal/sdk/proxy.go index 249c957..220b1fe 100644 --- a/internal/sdk/proxy.go +++ b/internal/sdk/proxy.go @@ -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() diff --git a/internal/sdk/proxy_test.go b/internal/sdk/proxy_test.go index 3f6ed00..eb43ce8 100644 --- a/internal/sdk/proxy_test.go +++ b/internal/sdk/proxy_test.go @@ -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) } } diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go index a71b890..ae17ab1 100644 --- a/third_party/homeagent-sdk/sdk/plugin.go +++ b/third_party/homeagent-sdk/sdk/plugin.go @@ -356,7 +356,7 @@ type PluginSDK struct { // proxyDecl 是反代声明的收集回调(内置插件经 DeclareProxy 声明服务)。 // 与上面的 API 字段同受 apiMu 保护——写方是内核注入,读方是插件 Start // 起的 goroutine。 - proxyDecl ProxyDeclarer + proxyReg ProxyRegistrar // apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。 // diff --git a/third_party/homeagent-sdk/sdk/proxy.go b/third_party/homeagent-sdk/sdk/proxy.go index 9928b37..4db65d3 100644 --- a/third_party/homeagent-sdk/sdk/proxy.go +++ b/third_party/homeagent-sdk/sdk/proxy.go @@ -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 +// —— 静默错路由,页面能开但功能全坏。 +// +// 于是「同一个插件必须同时支持两种形态」这条要求,等价于: +// +// **插件内部一律使用相对路径**(或基于 /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) } } diff --git a/third_party/homeagent-sdk/sdk/proxy_test.go b/third_party/homeagent-sdk/sdk/proxy_test.go index 5199686..1836fc2 100644 --- a/third_party/homeagent-sdk/sdk/proxy_test.go +++ b/third_party/homeagent-sdk/sdk/proxy_test.go @@ -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 应被拒(会覆盖整个门户)") + } +}