From 1e58af79d30f9287ab10de338e596c085819aabe Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 25 Sep 2026 12:13:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(webui):=20=E9=80=9A=E7=94=A8=E5=8F=8D?= =?UTF-8?q?=E5=90=91=E4=BB=A3=E7=90=86=20=E2=80=94=E2=80=94=20=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=A3=B0=E6=98=8E=E6=9C=8D=E5=8A=A1=EF=BC=8CHomeAgent?= =?UTF-8?q?=20=E6=8C=89=E5=AD=90=E5=9F=9F=E5=8F=8D=E4=BB=A3=E5=87=BA?= =?UTF-8?q?=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求:外部只装 HomeAgent 即可使用自带反代能力;用户只需穿透一个 webui 端口就能访问所有内部插件服务;认证与 WebSocket 支持都作为插件 的可声明项;插件 UI 要有可直接点击的入口。 实测 huawei_smarthome 插件的前端用**根绝对路径**(api('/api/status') → fetch('/api/status'))。挂在 /p// 这类路径前缀下,这些请求会打到 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=,因为子域 与门户不同源、浏览器不会自动带会话 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 败,且本改动完全 未触及该包),非本次引入。 --- internal/plugin/manifest.go | 14 + internal/plugins/remotedevice/plugin.go | 19 + internal/plugins/webui/dashboard.css | 13 + internal/plugins/webui/dashboard.js | 89 ++- internal/plugins/webui/handler.go | 19 +- internal/plugins/webui/plugin.go | 25 + internal/plugins/webui/proxy.go | 727 ++++++++++++++++++++ internal/plugins/webui/proxy_test.go | 715 +++++++++++++++++++ internal/sdk/plugin.go | 3 + internal/sdk/proxy.go | 109 +++ internal/sdk/proxy_test.go | 55 ++ third_party/homeagent-sdk/sdk/plugin.go | 5 + third_party/homeagent-sdk/sdk/proxy.go | 249 +++++++ third_party/homeagent-sdk/sdk/proxy_test.go | 111 +++ 14 files changed, 2151 insertions(+), 2 deletions(-) create mode 100644 internal/plugins/webui/proxy.go create mode 100644 internal/plugins/webui/proxy_test.go create mode 100644 internal/sdk/proxy.go create mode 100644 internal/sdk/proxy_test.go create mode 100644 third_party/homeagent-sdk/sdk/proxy.go create mode 100644 third_party/homeagent-sdk/sdk/proxy_test.go diff --git a/internal/plugin/manifest.go b/internal/plugin/manifest.go index a33fe8f..521b627 100644 --- a/internal/plugin/manifest.go +++ b/internal/plugin/manifest.go @@ -4,10 +4,15 @@ import ( "encoding/json" "os" "path/filepath" + + pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) const PackageExt = ".hmap" +// SDKProxyDecl 是 SDK 反代声明在本包的别名(避免调用方两处 import)。 +type SDKProxyDecl = pubsdk.ProxyDecl + // PluginManifest 每个插件目录中的 plugin.json 元数据。 type PluginManifest struct { Name string `json:"name"` @@ -25,6 +30,15 @@ type PluginManifest struct { Tags []string `json:"tags,omitempty"` Deprecated bool `json:"deprecated,omitempty"` + // Proxies 声明本插件需要 HomeAgent 反代出去的服务(自带 Web UI / HTTP API)。 + // + // 契约定义在公开 SDK(sdk.ProxyDecl),这里只做载体:HomeAgent 加载插件时 + // 读取并聚合,按 Host 子域路由从 webui 的同一端口转发出去。**不声明 = 不被反代**。 + // + // 字段解析忽略未知键(本仓无 DisallowUnknownFields),因此加这个字段 + // 对「旧内核读新插件」与「新内核读旧插件」都是无害的。 + Proxies []SDKProxyDecl `json:"proxies,omitempty"` + // Capabilities 声明本插件需要的内核能力组(§3.8 权限梯度)。 // // 取值见 internal/plugin/proc.KnownCapabilities(): diff --git a/internal/plugins/remotedevice/plugin.go b/internal/plugins/remotedevice/plugin.go index c3bba35..4fb24c1 100644 --- a/internal/plugins/remotedevice/plugin.go +++ b/internal/plugins/remotedevice/plugin.go @@ -77,6 +77,25 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { // ---- 设置 ---------------- s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen_addr", Default: defaultAddr, Type: "string", DisplayName: "监听地址", Description: "设备网关 HTTP/WS 监听地址(默认 127.0.0.1:9890,仅本机);填 127.0.0.1:0 让系统分配空闲端口", Category: "remotedevice"}) + // ---- 反代声明(HomeAgent 自带能力)---- + // + // 设备网关需要被外部访问(设备/客户端要连 WS),但它默认只监听本机 + // 127.0.0.1:9890。声明后由 webui 的对外端口按 Host 子域反代出去, + // **用户不需要额外开端口或配 frp 映射**。 + // + // auth=none 是**刻意的**:调用方是设备与嵌入式客户端,不可能持有浏览器 + // 门户会话;本服务**自身已有接入令牌**(ws_token / X-API-Key), + // 由 requireToken 强制校验。若这里声明 homeagent(默认值),会把设备链路 + // 全部挡在门户鉴权之外——那正是"认证必须可声明"的原因。 + // + // websocket=true:设备注册/命令下发走 WS 长连接。 + s.DeclareProxy(sdk.ProxyDecl{ + Name: "gateway", + Host: "devices", + Target: "127.0.0.1:9890", + WebSocket: true, + Auth: sdk.ProxyAuthNone, + }) s.Settings().RegisterDef(sdk.ConfigDef{Key: "ws_token", Default: "", Type: "password", DisplayName: "接入 Token", Description: "设备绑定/接入时使用的令牌;留空启动时自动生成", Category: "remotedevice"}) // 注意:不注册 authorized_devices 设置项 —— 鉴权在设备端执行(客户端存储), // 服务端不保存授权状态,避免 agent 经 config_set 工具自行授权。 diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css index 1a0dea4..8a4d77d 100644 --- a/internal/plugins/webui/dashboard.css +++ b/internal/plugins/webui/dashboard.css @@ -2989,3 +2989,16 @@ opacity: 0.75; font-variant-numeric: tabular-nums; } + +/* ===== 服务入口列表(反代插件服务的「点一下直接打开」) ===== */ +/* 与 run-* 系列同样的格槽风格:左状态点、中名称、右动作,避免行内文字挤在一起 */ +.svc-list{display:flex;flex-direction:column;gap:6px} +.svc-row{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:10px;background:var(--bg-input,rgba(13,13,22,.5));border:1px solid var(--border-color,rgba(255,255,255,.09));font-size:13px;transition:border-color .15s,background .15s} +.svc-row:hover{border-color:var(--sakura-400,#ff7fac)} +.svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0} +.svc-name{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px} +.svc-sub{color:var(--text-muted,#6e7284);font-size:12px;white-space:nowrap} +.svc-tag{font-size:10px;padding:2px 6px;border-radius:6px;background:var(--frost-300,#88c0d0);color:#0d0d16;font-weight:700;letter-spacing:.04em} +.svc-tag-warn{background:var(--danger,#d1383d);color:#fff} +.svc-path{color:var(--text-muted,#6e7284);font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;margin-left:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px} +.svc-err{color:var(--danger,#d1383d);font-size:11px;margin-left:auto;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index 0358cc3..91d923c 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -471,6 +471,9 @@ try { state.installedPlugins = await api("/plugins"); } catch (e) {} + try { + await loadProxyServices(); + } catch (e) {} try { await loadTerminals(); } catch (e) {} @@ -3803,12 +3806,96 @@ } // ===== Plugins ===== + // 「服务入口」:被反代的插件服务,点一下直接打开。 + // + // 为什么单独一张卡而不是塞进每个插件的详情:入口是**跨插件**的(同一个 + // webui 端口、不同子域),用户的心智是「我要打开某个服务」,不是 + // 「我要进某个插件的管理页」。 + // + // 链接带 ?__token=:子域与门户不同源,浏览器不会带上会话 cookie; + // 不带 token 会 401(这是刻意的受保护默认)。页面已登录,此处复用同一把 key。 + function renderProxyServicesCard() { + var svcs = state.proxyServices || []; + var base = state.proxyBaseDomain || "localhost"; + var html = + '

' + + __("服务入口", "Service Entry Points") + + "

"; + if (!svcs.length) { + return ( + html + + '

' + + __( + "暂无被反代的插件服务。插件在 plugin.json 的 proxies 字段里声明后会自动出现在这里。", + "No proxied plugin services yet. Declare them in plugin.json's proxies field.", + ) + + "

" + ); + } + var token = state.settings?.["plugin.webui.api_key"] || ""; + html += + '
' + + __("插件服务经 HomeAgent 同一端口反代,按子域区分(基域名 ", "Proxied through the same port, keyed by subdomain (base ") + + escHtml(base) + + __(")。点「打开」直接访问。", "). Click Open to visit.") + + "
"; + html += '
'; + svcs.forEach(function (s) { + var url = s.url || ""; + if (url && token) url += "?__token=" + encodeURIComponent(token); + var dot = s.ok ? "var(--ok, #22c55e)" : "var(--danger, #d1383d)"; + html += + '
' + + '' + + '' + escHtml(s.plugin_name || s.plugin) + "" + + '' + escHtml(s.name) + "" + + (s.websocket + ? 'WS' + : "") + + (s.auth === "none" + ? '' + + __("未保护", "unprotected") + + "" + : "") + + '' + escHtml(s.host + "." + base) + ""; + if (s.ok && url) { + html += + '' + + __("打开", "Open") + + ""; + } else { + html += + '' + + escHtml(s.error || __("不可用", "unavailable")) + + ""; + } + html += "
"; + }); + html += "
"; + return html; + } + + async function loadProxyServices() { + try { + var d = await api("/proxy/services"); + state.proxyServices = (d && d.services) || []; + state.proxyBaseDomain = (d && d.base_domain) || "localhost"; + } catch (e) { + state.proxyServices = []; + } + } + function renderPlugins() { var k = state.kernel; var plugins = k?.plugins || []; var tools = k?.tools || []; var installed = state.installedPlugins || []; - var html = + var html = renderProxyServicesCard(); + html += '

' + __("安装插件", "Install Plugin") + "

" + diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 932cf5a..dd56838 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -99,6 +99,9 @@ type Handler struct { term sdk.TerminalAPI llm sdk.LLMAPI + // hostPort 是 webui 实际监听的 ":port"(用于推导服务入口链接)。 + hostPort string + sessionMu sync.Mutex sessions map[string]time.Time @@ -440,7 +443,21 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // 用户上传文件的下载(uploads 目录,同一安全模型) mux.HandleFunc("/uploads/", h.requireWeb(h.handleUploads)) mux.HandleFunc("/v1/chat/completions", h.requireAPI(h.handleOpenAICompletions)) - mux.HandleFunc("/", h.requireWeb(h.handleStatic)) + // 反代服务入口清单:给前端渲染「插件 UI」选项卡。 + // 走 requireAPI:清单本身含上游地址,属于管理面信息,不该匿名可读。 + mux.HandleFunc("/api/v1/proxy/services", h.requireAPI(h.handleProxyServices)) + mux.HandleFunc("/api/v1/proxy", h.requireAPI(h.handleProxyInfo)) + // "/" 兜底:**先**尝试按 Host 分发到插件反代,不是插件子域才落到主站静态页。 + // 顺序很重要——ServeMux 只会把未被更具体模式匹配的请求交给这里。 + mux.HandleFunc("/", h.handleRoot) +} + +// handleRoot 是根路由兜底:先看是不是插件反代的子域,不是再走主站。 +func (h *Handler) handleRoot(w http.ResponseWriter, r *http.Request) { + if h.serveProxyHost(w, r) { + return + } + h.requireWeb(h.handleStatic)(w, r) } func (h *Handler) handleLoginPage(w http.ResponseWriter, r *http.Request) { diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go index 28441d8..229c21b 100644 --- a/internal/plugins/webui/plugin.go +++ b/internal/plugins/webui/plugin.go @@ -231,6 +231,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.Settings().RegisterDef(sdk.ConfigDef{Key: "username", Default: "admin", Type: "string", DisplayName: "登录用户名", Description: "Web 控制台登录用户名", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "password", Default: "", Type: "password", DisplayName: "Web 控制台登录密码", Description: "Web 控制台登录密码", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "session_ttl_hours", Default: "24", Type: "int", DisplayName: "会话时长(小时)", Description: "登录 cookie 有效时长", Category: "webui"}) + // ---- 通用反代(HomeAgent 自带能力)---- + // 基域名:默认 localhost ⇒ <标签>.localhost:<端口> 开箱即用(RFC 6761 强制 + // 解析到 loopback,无需 DNS/证书/hosts)。远程访问时改成本机可达的域名, + // 如 webui.example.com ⇒ <标签>.webui.example.com。 + s.Settings().RegisterDef(sdk.ConfigDef{Key: "base_domain", Default: "localhost", Type: "string", DisplayName: "反代基域名", Description: "插件服务反代的基域名。默认 localhost,此时 <插件标签>.localhost:<端口> 直接可用;远程访问填如 webui.example.com", Category: "webui"}) + // 手填反代条目(自动发现之外的补充):每行 `<标签> <上游地址> [ws] [auth=none]` + s.Settings().RegisterDef(sdk.ConfigDef{Key: "proxy_routes", Default: "", Type: "text", DisplayName: "手填反代条目", Description: "每行一条:<子域标签> <上游地址> [ws] [auth=none|homeagent]。插件的 proxies 声明会自动发现,这里只用于补充未声明/第三方服务。例:grafana 127.0.0.1:3000", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_enabled", Default: "false", Type: "bool", DisplayName: "设备网关反代", Description: "启用后 /api/v1/device/* 反代到 remotedevice 插件(默认关闭,避免硬耦合)", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_addr", Default: "127.0.0.1:9890", Type: "string", DisplayName: "设备网关地址", Description: "remotedevice 插件的内部监听地址", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_token", Default: "", Type: "password", DisplayName: "设备网关令牌", Description: "访问 remotedevice 的 token(与 remotedevice 的 ws_token 一致)", Category: "webui"}) @@ -269,6 +276,20 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { return nil }) + // 反代接线:手填条目 + 自动发现回调。 + // 自动发现读插件目录里的 plugin.json(webui 已能拿到该目录),因此 + // **无需给内核接口加方法**即可发现声明,插件进程没起来也照样可见。 + if v, _ := s.Settings().Get("proxy_routes"); v != nil { + if raw, ok := v.(string); ok { + SetManualProxyRoutes(raw) + } + } + if pm := s.PluginMgr(); pm != nil { + dir := pm.PluginDir() + SetProxyDeclProvider(func() []proxyDecl { return readPluginProxyDecls(dir) }) + } + InvalidateProxyRoutes() + p.handler = NewHandler(s) p.handler.RegisterRoutes(p.mux) @@ -283,6 +304,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { if err != nil { return fmt.Errorf("webui: 监听 %s 失败: %w", addr, err) } + // 记录实际监听端口:「服务入口」链接必须带同一端口(单端口穿透的前提)。 + if _, port, err := net.SplitHostPort(ln.Addr().String()); err == nil { + p.handler.hostPort = ":" + port + } p.server = &http.Server{Handler: p.handler.logged(p.mux)} go func() { if err := p.server.Serve(ln); err != nil && err != http.ErrServerClosed { diff --git a/internal/plugins/webui/proxy.go b/internal/plugins/webui/proxy.go new file mode 100644 index 0000000..b8cb838 --- /dev/null +++ b/internal/plugins/webui/proxy.go @@ -0,0 +1,727 @@ +package webui + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + pluginpkg "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// 通用反向代理:插件声明自带 HTTP 服务(plugin.json 的 proxies), +// HomeAgent 按 **Host 子域标签** 把它们从 webui 的同一端口反代出去。 +// +// ## 为什么是 Host 路由而不是路径前缀 +// +// 插件自带 UI 普遍使用根绝对路径(实测 huawei_smarthome 的前端是 +// `api('/api/status')` → `fetch('/api/status')`)。若挂在 `/p/huawei/` 下, +// 这些请求会打到 HomeAgent 自己的 `/api/status`,**静默错路由**。路径前缀方案 +// 要么要求所有插件改前端,要么做 HTML/JS 内容重写——后者对拼进 JS 字符串的 +// 绝对路径只是"按概率能用",会产生「页面能开、某个按钮就坏」的静默故障。 +// +// Host 路由下,插件前端的根路径天然正确,**插件零改动**。且它正好匹配 +// 「只穿透一个端口」:webui 监听 0.0.0.0:8080,按 Host 分发;外层 frp/nginx +// 是单端口隧道,**不需要为每个插件加一条映射**。 +// +// ## 默认基座:*.localhost(零配置) +// +// RFC 6761 规定 `*.localhost` 必须解析到 loopback,现代浏览器原生支持。 +// 于是默认基座是 `<标签>.localhost:`——装完即可用, +// **不需要 DNS、证书、/etc/hosts 或任何配置**。远程访问时配置 `base_domain` +// (如 webui.example.com)即切成 `<标签>.webui.example.com`。 +// +// ## 认证 +// +// 逐条由插件声明(ProxyAuthHomeAgent / ProxyAuthNone),默认 HomeAgent 统一保护。 +// 但 Host 路由下**子域与主门户不同源**,浏览器不会把门户的 homeagent_session +// 发给子域——所以受保护模式下由反代层校验门户会话/API Key,校验通过后放行。 +// 详见 authorizeProxy。 + +// ProxyRoute 是一条**已解析**的反代路由(声明 + 归属插件 + 校验结果)。 +type ProxyRoute struct { + Plugin string // 声明该服务的插件名 + Name string // 声明内的服务标识(展示用,如 "ui") + Host string // 子域标签(小写,已归一化) + Target string // 上游地址(原样,含可能的 scheme/路径前缀) + WS bool // 是否允许 WebSocket 升级 + Auth string // 生效的鉴权模式(已归一化) + Err string // 非空表示该条声明被拒绝及原因(不参与路由,仅展示) + + upstream *url.URL + reverse *httputil.ReverseProxy +} + +// proxyTable 是全部反代路由的**不可变快照**。 +// +// 用快照 + 原子替换而不是加锁读写 map:反代处于每个请求的热路径上, +// 而声明只在启动/插件重载时变化。读路径无锁,重载时整体换指针。 +type proxyTable struct { + routes map[string]*ProxyRoute // key = 小写 host 标签 + ordered []*ProxyRoute // 稳定顺序(展示/配置页用) + base string // 基域名("" 表示用 localhost) +} + +var ( + proxyMu sync.RWMutex + proxySnap *proxyTable + proxyDirty bool // 声明有变更、需要重建快照 + proxySeenVer int64 // 上次建表时看到的内置声明版本号 +) + +// manualProxyRoutes 是「手填」来源:用户在 webui 设置页配置的额外/覆盖条目。 +// +// 保持原始文本(每行 `标签 上游地址 [选项]`),解析在 rebuildProxyTable 里做, +// 解析失败不会让设置页爆炸,而是作为一条 Err 条目展示出来。 +var manualProxyRoutes string + +// SetManualProxyRoutes 注入手填的声明(插件 Start 时从设置读取)。 +func SetManualProxyRoutes(raw string) { + proxyMu.Lock() + manualProxyRoutes = raw + proxyDirty = true + proxySnap = nil + proxyMu.Unlock() +} + +// InvalidateProxyRoutes 标记声明有变更(插件启停/重载后调用)。 +func InvalidateProxyRoutes() { + proxyMu.Lock() + proxyDirty = true + proxySnap = nil + proxyMu.Unlock() +} + +// proxyBaseDomain 返回基域名:配置了就用,否则回落到 localhost。 +func proxyBaseDomain(settings sdk.SettingsAPI) string { + if settings != nil { + if v, err := settings.Get("base_domain"); err == nil && v != nil { + if s, ok := v.(string); ok { + if d := strings.Trim(strings.ToLower(strings.TrimSpace(s)), "."); d != "" { + return d + } + } + } + } + return "localhost" +} + +// proxyHostLabel 从请求 Host 里抽出子域标签。 +// +// 处理三种输入: +// - `huawei.localhost:8080` → "huawei" +// - `huawei.webui.example.com`(base_domain=webui.example.com)→ "huawei" +// - `webui.example.com`(基域名本身)→ ""(不是插件路由,交给主站) +// +// 刻意只认**单层**子域(不匹配 `a.b.webui.example.com`):多级标签会让 +// 「哪个是插件、哪个是基域」变得含混,且容易被 `..` 类输入绕过判断。 +func proxyHostLabel(host, base string) string { + h := strings.ToLower(strings.TrimSpace(host)) + if h == "" { + return "" + } + if hp, _, err := net.SplitHostPort(h); err == nil { + h = hp + } else if i := strings.LastIndexByte(h, ':'); i >= 0 { + // 无括号的 IPv6 等异常输入:丢弃端口段 + h = h[:i] + } + h = strings.Trim(h, ".") + if base == "" { + base = "localhost" + } + base = strings.ToLower(base) + if h == base { + return "" + } + suffix := "." + base + if !strings.HasSuffix(h, suffix) { + return "" + } + label := strings.TrimSuffix(h, suffix) + // 只认单层标签:出现 `.` 说明是多级子域,不接。 + if label == "" || strings.Contains(label, ".") { + return "" + } + return label +} + +// buildProxyTable 由「插件声明 + 手填条目」构建路由表。 +// +// 冲突与非法条目的处理原则:**宁可报出来,不可静默丢弃**。被拒绝的条目 +// 仍会出现在表里(Err 非空),在配置页可见;只是不参与路由。 +func buildProxyTable(decls []proxyDecl, manualText string, settings sdk.SettingsAPI) *proxyTable { + base := proxyBaseDomain(settings) + t := &proxyTable{routes: map[string]*ProxyRoute{}, base: base} + + add := func(r *ProxyRoute) { + t.ordered = append(t.ordered, r) + if r.Err != "" { + return + } + key := strings.ToLower(r.Host) + if prev, dup := t.routes[key]; dup { + // 冲突:保留先到者,后来者标错。**不做后者覆盖**——那会让先声明者 + // 静默消失,用户以为两个插件都挂上了。 + r.Err = fmt.Sprintf("子域标签 %q 已被插件 %s 的服务 %s 占用", r.Host, prev.Plugin, prev.Name) + return + } + t.routes[key] = r + } + + for _, d := range decls { + r := &ProxyRoute{ + Plugin: d.Plugin, + Name: d.Name, + Host: d.Host, + Target: d.Target, + WS: d.WebSocket, + Auth: sdk.EffectiveProxyAuth(d.Auth), + } + if msg := sdk.ValidateProxyDecl(sdk.ProxyDecl{ + Name: d.Name, Host: d.Host, Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth, + }); msg != "" { + r.Err = msg + } else if r.Name == "" { + r.Name = d.Plugin + } + add(r) + } + + for _, d := range parseManualRoutes(manualText) { + r := &ProxyRoute{ + Plugin: "manual", + Name: d.Name, + Host: d.Host, + Target: d.Target, + WS: d.WebSocket, + Auth: sdk.EffectiveProxyAuth(d.Auth), + } + if msg := sdk.ValidateProxyDecl(sdk.ProxyDecl{ + Name: d.Name, Host: d.Host, Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth, + }); msg != "" { + r.Err = msg + } + add(r) + } + + // 给合法路由预建 ReverseProxy(每条一个,避免每请求分配)。 + for _, r := range t.ordered { + if r.Err != "" { + continue + } + u, err := parseUpstream(r.Target) + if err != nil { + r.Err = "解析上游地址失败: " + err.Error() + delete(t.routes, strings.ToLower(r.Host)) + continue + } + r.upstream = u + r.reverse = newReverseProxy(u) + log.Printf("[webui] 反代: %s.%s → %s (plugin=%s ws=%v auth=%s)", + r.Host, base, r.Target, r.Plugin, r.WS, r.Auth) + } + + sort.SliceStable(t.ordered, func(i, j int) bool { + if t.ordered[i].Err != t.ordered[j].Err { + return t.ordered[i].Err == "" + } + if t.ordered[i].Host != t.ordered[j].Host { + return t.ordered[i].Host < t.ordered[j].Host + } + return t.ordered[i].Plugin < t.ordered[j].Plugin + }) + return t +} + +// parseUpstream 把声明里的 Target 解析成 *url.URL。 +// 允许省略 scheme(默认 http)与端口(http→80 / https→443)。 +func parseUpstream(target string) (*url.URL, error) { + s := strings.TrimSpace(target) + if !strings.Contains(s, "://") { + s = "http://" + s + } + u, err := url.Parse(s) + if err != nil { + return nil, err + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("只支持 http/https 上游,得到 %q", u.Scheme) + } + if u.Host == "" { + return nil, fmt.Errorf("缺少主机部分") + } + return u, nil +} + +// newReverseProxy 构造一个 httputil.ReverseProxy。 +// +// 一次性消除手写反代的历史缺陷: +// 1. **逐帧 Flush**:httputil.ReverseProxy 在响应带 FlushInterval 或识别到 +// text/event-stream 时会 Flush;这里显式设 -1(立即 flush),否则上游的 +// SSE/流式响应会被缓冲到上游关闭才下发(旧实现实测:3 帧 200ms 间隔的 +// 流,客户端在 +600ms 一次性收到全部)。 +// 2. **不跟随上游 3xx**:旧实现用 http.DefaultClient(默认跟最多 10 跳), +// 上游 302 到内网地址时反代自己跟过去、失败就回 502,并把内网 URL +// 泄给客户端。ReverseProxy 默认不跟随重定向,3xx 原样透传。 +// 3. **补齐转发头**:SetXForwarded 注入 X-Forwarded-For/Host/Proto, +// 旧实现完全不注入,上游无法判断真实来源。 +func newReverseProxy(u *url.URL) *httputil.ReverseProxy { + rp := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(u) + pr.SetXForwarded() + // 透传子域标签给上游(插件据此可感知自己被挂在哪个标签下)。 + pr.Out.Header.Set("X-HA-Proxy-Host", pr.In.Host) + // 上游可能自带鉴权,浏览器带来的门户 cookie 不应泄漏给它。 + pr.Out.Header.Del("Cookie") + pr.Out.Header.Del("Authorization") + pr.Out.Header.Del("X-API-Key") + }, + FlushInterval: -1, // 立即 flush:SSE/长轮询逐帧下发 + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("[webui] 反代 %s 失败: %v", r.Host, err) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, `{"error":"上游不可达: %s"}`, strings.ReplaceAll(err.Error(), `"`, `'`)) + }, + ModifyResponse: func(resp *http.Response) error { + // 上游 Set-Cookie 的 Path 若为 "/",会因 Host 路由而只作用于该子域, + // 天然隔离,无需重写。但 Domain 若被上游写成裸域会把 cookie 打到 + // 主门户域上,属于跨插件越权——剥掉它,交给浏览器按当前 host 收窄。 + cookies := resp.Cookies() + if len(cookies) > 0 { + resp.Header.Del("Set-Cookie") + for _, c := range cookies { + c.Domain = "" + resp.Header.Add("Set-Cookie", c.String()) + } + } + return nil + }, + } + return rp +} + +// currentProxyTable 返回当前快照;需要时按声明重建。 +// +// declProvider 由插件在 Start 时注入(见 handler.go 的 SetProxyDeclProvider), +// 它负责从插件目录读 manifest 并归一化。这里做成回调而不是直接依赖内部状态, +// 是为了让反代层可单测(测试注入假声明)。 +var declProvider func() []proxyDecl + +// SetProxyDeclProvider 注入「读取全部插件声明」的回调。 +func SetProxyDeclProvider(fn func() []proxyDecl) { declProvider = fn } + +func currentProxyTable() *proxyTable { + // 内置插件的声明是运行期登记的(插件 Start 时),版本号一变就重建—— + // 比每次请求都重新聚合一遍便宜得多。 + if sdk.BuiltinProxyVersion() != proxySeenVer { + proxyMu.Lock() + proxyDirty = true + proxyMu.Unlock() + } + proxyMu.RLock() + if !proxyDirty && proxySnap != nil { + t := proxySnap + proxyMu.RUnlock() + return t + } + manual := manualProxyRoutes + proxyMu.RUnlock() + + proxyMu.Lock() + defer proxyMu.Unlock() + if proxySnap != nil && !proxyDirty { + return proxySnap + } + var decls []proxyDecl + if declProvider != nil { + decls = declProvider() + } + // 内置插件的运行期声明(无 plugin.json,扫目录发现不到)。 + for plugin, list := range sdk.BuiltinProxyDecls() { + for _, d := range list { + host := d.Host + if host == "" { + host = sdk.NormalizeProxyHost(plugin) + } + name := d.Name + if name == "" { + name = "service" + } + decls = append(decls, proxyDecl{ + Plugin: plugin, Name: name, Host: strings.ToLower(host), + Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth, + }) + } + } + proxySnap = buildProxyTable(decls, manual, nil) + proxyDirty = false + proxySeenVer = sdk.BuiltinProxyVersion() + return proxySnap +} + +// serveProxyHost 是挂在根路由前的 Host 分发入口。 +// 返回 true 表示已处理该请求。 +func (h *Handler) serveProxyHost(w http.ResponseWriter, r *http.Request) bool { + base := "localhost" + if h.settings != nil { + base = proxyBaseDomain(h.settings) + } + label := proxyHostLabel(r.Host, base) + if label == "" { + return false + } + t := currentProxyTable() + route, ok := t.routes[label] + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": fmt.Sprintf("没有插件声明子域 %q(基域名 %s)", label, base), + "hint": "在插件 plugin.json 的 proxies 里声明,或在 webui 设置页手填", + }) + return true + } + + // WebSocket 升级必须由插件显式声明。未声明时明确拒绝,而不是把升级请求 + // 当普通请求透传——后者表现为前端不断重连、日志里看不出原因。 + if isWebSocketUpgrade(r) && !route.WS { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": fmt.Sprintf("插件 %s 的服务 %s 未声明 websocket", route.Plugin, route.Name), + }) + return true + } + + if route.Auth == sdk.ProxyAuthHomeAgent && !h.authorizeProxy(w, r) { + return true // 已写 401 + } + + if route.upstream == nil || route.reverse == nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "路由未就绪"}) + return true + } + route.reverse.ServeHTTP(w, r) + return true +} + +// authorizeProxy 校验受保护路由的访问者。 +// +// Host 路由下子域与门户不同源,浏览器**不会**自动带上门户 cookie;因此这里 +// 接受三种凭证,任一通过即放行: +// 1. 门户会话 cookie(用户同浏览器访问过门户时; +// SameSite=Lax 在同站子域导航下会带上,同站不同源仍算同一 site); +// 2. X-API-Key / Bearer(脚本与非浏览器客户端); +// 3. `?__token=` 查询参数(便于在新标签页里直接打开,见"服务入口")。 +// +// 三者都没有时返回 401 并给出**可操作提示**(告诉用户先登录门户), +// 而不是把请求静默透传给上游。 +func (h *Handler) authorizeProxy(w http.ResponseWriter, r *http.Request) bool { + if h.validAPIKey(r) || h.validSession(r) { + return true + } + // ?__token= 形式:与 X-API-Key 同一把密钥,用于「点一下直接打开」的入口。 + if tok := strings.TrimSpace(r.URL.Query().Get("__token")); tok != "" { + apiKey, _, _, _ := h.getWebUIConfig() + if apiKey != "" && tok == apiKey { + return true + } + } + w.Header().Set("WWW-Authenticate", `Bearer realm="homeagent"`) + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "error": "该服务由 HomeAgent 统一保护,需要先登录门户或用 X-API-Key 访问", + "hint": "浏览器先访问门户登录;脚本用 -H 'X-API-Key: ' 或 ?__token=", + }) + return false +} + +func isWebSocketUpgrade(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") +} + +// proxyServiceEntry 是「服务入口」条目:给前端渲染选项卡用。 +type proxyServiceEntry struct { + Plugin string `json:"plugin"` + PluginZh string `json:"plugin_name"` + Name string `json:"name"` + Host string `json:"host"` + URL string `json:"url"` // 完整可点 URL(带端口/协议,按当前请求推导) + Auth string `json:"auth"` // homeagent | none + WS bool `json:"websocket"` + Target string `json:"target"` + OK bool `json:"ok"` // false = 声明被拒或上游不可达(见 error) + Error string `json:"error,omitempty"` +} + +// listProxyServices 汇总反代服务清单(含被拒条目,供配置页排错)。 +// schemePort 由调用方按当前请求推导(本机 http:8080 / 远程 https:443 等)。 +func (h *Handler) listProxyServices(scheme, hostPort string) []proxyServiceEntry { + t := currentProxyTable() + metas := map[string]sdk.PluginMeta{} + if h.pluginMgr != nil { + metas = h.pluginMgr.PluginMetas() + } + out := make([]proxyServiceEntry, 0, len(t.ordered)) + for _, r := range t.ordered { + e := proxyServiceEntry{ + Plugin: r.Plugin, + Name: r.Name, + Host: r.Host, + 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 + } + if e.PluginZh == "" { + e.PluginZh = r.Plugin + } + if r.Err == "" { + e.URL = fmt.Sprintf("%s://%s.%s%s", scheme, r.Host, t.base, hostPort) + } + out = append(out, e) + } + return out +} + +// ---- 声明读取(自动发现)---- + +// proxyDecl 是归一化后的插件声明(与 SDK 的 ProxyDecl 同形,另带插件名)。 +type proxyDecl struct { + Plugin string + Name string + Host string + Target string + WebSocket bool + Auth string +} + +// readPluginProxyDecls 扫描插件目录里的 plugin.json,聚合 proxies 声明。 +// +// 为什么在 webui 侧读而不是问内核:manifest 解析(internal/plugin.ReadManifest) +// 是纯文件读取,webui 已能拿到插件目录(sdk.PluginManager.PluginDir()), +// 这样**无需给内核接口加方法**即可实现自动发现——插件进程没起来也照样发现, +// 便于给出「声明了但不可达」的准确报错。 +// +// 内置插件(编译进内核、无独立目录)不参与:它们要暴露服务应走内核自身路由。 +func readPluginProxyDecls(pluginDir string) []proxyDecl { + if pluginDir == "" { + return nil + } + entries, err := readDirNames(pluginDir) + if err != nil { + log.Printf("[webui] 读取插件目录失败(反代自动发现跳过): %v", err) + return nil + } + var out []proxyDecl + for _, name := range entries { + m, err := pluginpkg.ReadManifest(filepath.Join(pluginDir, name)) + if err != nil { + continue // 非插件目录/无 manifest:静默跳过 + } + for i, p := range m.Proxies { + host := strings.TrimSpace(p.Host) + explicitHost := host != "" + if !explicitHost { + host = sdk.NormalizeProxyHost(m.Name) + } + sname := strings.TrimSpace(p.Name) + if sname == "" { + sname = "service" + } + // 只在**未显式指定** host 时加序号:插件写死的 host 是它的 + // 刻意选择(如 remotedevice 的 "devices"),被自动编号覆盖会让 + // 声明静默失效——用户按文档访问 devices. 就是 404。 + if !explicitHost && i > 0 { + host = fmt.Sprintf("%s-%d", host, i+1) + } + out = append(out, proxyDecl{ + Plugin: m.Name, + Name: sname, + Host: strings.ToLower(host), + Target: p.Target, + WebSocket: p.WebSocket, + Auth: p.Auth, + }) + } + } + return out +} + +// parseManualRoutes 解析手填条目。格式(每行一条,空行与 # 注释跳过): +// +// <子域标签> <上游地址> [ws] [auth=none|homeagent] +// +// 例: +// +// grafana 127.0.0.1:3000 +// devices 127.0.0.1:9890 ws auth=none +// +// 手填条目与插件声明同表竞争:先声明者占住标签,后来者(无论来源)被标错, +// 便于用户发现"我手填的标签和某插件撞了"。 +func parseManualRoutes(text string) []proxyDecl { + var out []proxyDecl + for _, rawLine := range strings.Split(text, "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + out = append(out, proxyDecl{ + Plugin: "manual", Name: "invalid", Host: "invalid", + Target: "", + Auth: "homeagent", + }) + continue + } + d := proxyDecl{Plugin: "manual", Name: fields[0], Host: strings.ToLower(fields[0]), Target: fields[1]} + for _, opt := range fields[2:] { + lo := strings.ToLower(opt) + switch { + case lo == "ws" || lo == "websocket": + d.WebSocket = true + case strings.HasPrefix(lo, "auth="): + d.Auth = strings.TrimPrefix(lo, "auth=") + default: + d.Target = "" + } + } + out = append(out, d) + } + return out +} + +// ---- 小工具(避免为两行逻辑引入额外依赖文件)---- + +func readDirNames(dir string) ([]string, error) { + f, err := os.Open(dir) + if err != nil { + return nil, err + } + defer f.Close() + return f.Readdirnames(-1) +} + +// proxyUpstreamReachable 探测上游是否可达(供服务入口列表显示状态)。 +func proxyUpstreamReachable(target string) error { + u, err := parseUpstream(target) + if err != nil { + return err + } + host := u.Host + if _, _, err := net.SplitHostPort(host); err != nil { + if u.Scheme == "https" { + host = net.JoinHostPort(host, "443") + } else { + host = net.JoinHostPort(host, "80") + } + } + ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond) + defer cancel() + var d net.Dialer + c, err := d.DialContext(ctx, "tcp", host) + if err != nil { + return err + } + return c.Close() +} + +// ---- HTTP 接口:服务入口清单 ---- + +// proxySchemeAndPort 按当前请求推导对外访问的 scheme 与端口。 +// +// 为什么不能写死 http:8080:远程访问常经 nginx/frp(https:443), +// 写死会让「服务入口」给出的链接点不开。优先取反代头,回退请求自身。 +func (h *Handler) proxySchemeAndPort(r *http.Request) (string, string) { + scheme := "http" + if p := r.Header.Get("X-Forwarded-Proto"); p != "" { + scheme = strings.ToLower(strings.TrimSpace(strings.Split(p, ",")[0])) + } else if r.TLS != nil { + scheme = "https" + } + host := r.Host + if h := r.Header.Get("X-Forwarded-Host"); h != "" { + host = strings.TrimSpace(strings.Split(h, ",")[0]) + } + // Host 可能带端口;子域链接要沿用同一个端口(单端口穿透的前提)。 + if _, port, err := net.SplitHostPort(host); err == nil { + return scheme, ":" + port + } + if scheme == "https" { + return scheme, "" // 443 省略 + } + // 按 Host 头推断不出端口(如反代层剥了),退回到监听地址的端口。 + if _, port, err := net.SplitHostPort(r.Host); err == nil { + return scheme, ":" + port + } + if h.hostPort != "" { + return scheme, h.hostPort + } + return scheme, "" +} + +// handleProxyServices 返回反代服务清单(含被拒条目与可达性)。 +func (h *Handler) handleProxyServices(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + scheme, port := h.proxySchemeAndPort(r) + svcs := h.listProxyServices(scheme, port) + // 可达性探测:并发带超时,避免一个坏上游拖住整个清单。 + var wg sync.WaitGroup + for i := range svcs { + if !svcs[i].OK { + continue + } + wg.Add(1) + go func(e *proxyServiceEntry) { + defer wg.Done() + if err := proxyUpstreamReachable(e.Target); err != nil { + e.OK = false + e.Error = "上游不可达: " + err.Error() + } + }(&svcs[i]) + } + wg.Wait() + base := "localhost" + if h.settings != nil { + base = proxyBaseDomain(h.settings) + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "services": svcs, + "base_domain": base, + "scheme": scheme, + "port": port, + }) +} + +// handleProxyInfo 返回反代能力总览(给设置页说明用)。 +func (h *Handler) handleProxyInfo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + t := currentProxyTable() + mode := "host" + writeJSON(w, http.StatusOK, map[string]interface{}{ + "mode": mode, + "base_domain": t.base, + "count": len(t.routes), + "total": len(t.ordered), + "manual": strings.TrimSpace(manualProxyRoutes) != "", + }) +} diff --git a/internal/plugins/webui/proxy_test.go b/internal/plugins/webui/proxy_test.go new file mode 100644 index 0000000..9182274 --- /dev/null +++ b/internal/plugins/webui/proxy_test.go @@ -0,0 +1,715 @@ +package webui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// proxyTestSettings 造一份带 api_key 的 webui 插件设置,供受保护路由用 +// X-API-Key 通过鉴权(真实部署里浏览器走门户会话,脚本走 key)。 +func proxyTestSettings(t *testing.T) *sdk.PluginSDK { + t.Helper() + cfgReg := internalConfig.NewConfigRegistry("") + seedWebUIConfig(cfgReg) + return testSDK(sdk.SDKConfig{Settings: sdk.NewSettings("webui", cfgReg)}) +} + +// authHeaders 给受保护路由的测试请求带上凭证。 +func authHeaders(r *http.Request) { + r.Header.Set("X-API-Key", "test-api-key") +} + +// ---- 子域标签抽取 ---- + +func TestProxyHostLabel(t *testing.T) { + cases := []struct { + host, base, want string + }{ + // 默认基座:*.localhost(零配置可用) + {"huawei.localhost:8080", "localhost", "huawei"}, + {"huawei.localhost", "localhost", "huawei"}, + {"HUAWEI.LOCALHOST:8080", "localhost", "huawei"}, // 大小写不敏感 + // 基域名自身不是插件路由 + {"localhost:8080", "localhost", ""}, + {"localhost", "localhost", ""}, + // 自定义基域名 + {"devices.webui.example.com", "webui.example.com", "devices"}, + {"webui.example.com", "webui.example.com", ""}, + // 多级子域不接(避免"哪个是插件"含混) + {"a.b.webui.example.com", "webui.example.com", ""}, + // 非本基域名的 Host 不接 + {"evil.com", "webui.example.com", ""}, + {"notlocalhost.com", "localhost", ""}, + // 边界/异常输入 + {"", "localhost", ""}, + {" ", "localhost", ""}, + {".localhost:8080", "localhost", ""}, + {"..localhost", "localhost", ""}, + // 后缀但标签为空 + {".webui.example.com", "webui.example.com", ""}, + } + for _, c := range cases { + if got := proxyHostLabel(c.host, c.base); got != c.want { + t.Errorf("proxyHostLabel(%q, %q) = %q,期望 %q", c.host, c.base, got, c.want) + } + } +} + +// ---- 手填条目解析 ---- + +func TestParseManualRoutes(t *testing.T) { + text := ` +# 注释行 +grafana 127.0.0.1:3000 +devices 127.0.0.1:9890 ws auth=none + +prom http://127.0.0.1:9090 +` + got := parseManualRoutes(text) + if len(got) != 3 { + t.Fatalf("解析出 %d 条,期望 3:%+v", len(got), got) + } + if got[0].Host != "grafana" || got[0].Target != "127.0.0.1:3000" || got[0].WebSocket { + t.Errorf("第 1 条不对: %+v", got[0]) + } + if got[1].Host != "devices" || !got[1].WebSocket || got[1].Auth != "none" { + t.Errorf("第 2 条没解析出 ws/auth=none: %+v", got[1]) + } + if got[2].Target != "http://127.0.0.1:9090" { + t.Errorf("第 3 条带 scheme 的地址被破坏: %+v", got[2]) + } + // 空白与注释行不得产出条目 + if len(parseManualRoutes("\n\n#x\n")) != 0 { + t.Error("空行/注释行不应产出条目") + } + // 大写标签归一化为小写 + if r := parseManualRoutes("GRAFANA 127.0.0.1:3000"); len(r) != 1 || r[0].Host != "grafana" { + t.Errorf("标签未归一化为小写: %+v", r) + } +} + +// ---- 路由表构建:冲突不得静默覆盖 ---- + +func TestBuildProxyTableConflictNotSilentlyOverridden(t *testing.T) { + decls := []proxyDecl{ + {Plugin: "a", Name: "ui", Host: "dup", Target: "127.0.0.1:1001"}, + {Plugin: "b", Name: "ui", Host: "dup", Target: "127.0.0.1:1002"}, + } + prev := declProvider + SetProxyDeclProvider(func() []proxyDecl { return decls }) + manualProxyRoutes = "" + InvalidateProxyRoutes() + t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() }) + + tbl := currentProxyTable() + // 先到者占住标签 + r, ok := tbl.routes["dup"] + if !ok { + t.Fatal("先声明的条目应占住标签") + } + if r.Plugin != "a" { + t.Errorf("标签被后者覆盖了:当前属于 %s,期望 a(后者应被判冲突)", r.Plugin) + } + // 后者必须仍然可见(带错误),而不是静默消失 + var loser *ProxyRoute + for _, x := range tbl.ordered { + if x.Plugin == "b" { + loser = x + } + } + if loser == nil { + t.Fatal("冲突的后者从表里消失了——用户将无法察觉两个插件撞了标签") + } + if loser.Err == "" { + t.Error("冲突条目应带错误说明") + } +} + +// ---- 非法声明不得进路由,但必须可见 ---- + +func TestBuildProxyTableKeepsInvalidVisible(t *testing.T) { + decls := []proxyDecl{ + {Plugin: "bad", Name: "x", Host: "bad_host", Target: "127.0.0.1:1"}, // host 非法 + {Plugin: "good", Name: "y", Host: "good", Target: "127.0.0.1:2"}, + } + prev := declProvider + SetProxyDeclProvider(func() []proxyDecl { return decls }) + manualProxyRoutes = "" + InvalidateProxyRoutes() + t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() }) + + tbl := currentProxyTable() + if _, ok := tbl.routes["bad_host"]; ok { + t.Error("非法声明不应参与路由") + } + if _, ok := tbl.routes["good"]; !ok { + t.Error("合法声明应参与路由") + } + var bad *ProxyRoute + for _, x := range tbl.ordered { + if x.Plugin == "bad" { + bad = x + } + } + if bad == nil || bad.Err == "" { + t.Error("非法声明必须留在表里并带原因(配置页要能看见)") + } +} + +// ---- Host 分发端到端:这是「通用反代」的核心行为 ---- + +func TestServeProxyHostRoutesByHost(t *testing.T) { + var gotPath, gotXFF, gotProto, gotCookie string + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotXFF = r.Header.Get("X-Forwarded-For") + gotProto = r.Header.Get("X-Forwarded-Proto") + gotCookie = r.Header.Get("Cookie") + w.WriteHeader(200) + w.Write([]byte("from-upstream")) + })) + defer up.Close() + + prev := declProvider + SetProxyDeclProvider(func() []proxyDecl { + return []proxyDecl{{Plugin: "demo", Name: "ui", Host: "demo", Target: up.Listener.Addr().String()}} + }) + manualProxyRoutes = "" + InvalidateProxyRoutes() + t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() }) + + h := NewHandler(proxyTestSettings(t)) + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/api/status", nil) + r.Host = "demo.localhost:8080" + authHeaders(r) + r.Header.Set("Cookie", "homeagent_session=SECRET") + if !h.serveProxyHost(rec, r) { + t.Fatal("插件子域的请求应被反代处理") + } + if rec.Code != 200 || rec.Body.String() != "from-upstream" { + t.Fatalf("反代未透传:code=%d body=%q", rec.Code, rec.Body.String()) + } + // ★ 插件前端的根绝对路径必须原样到达上游(这正是选 Host 路由的理由) + if gotPath != "/api/status" { + t.Errorf("上游收到的路径 = %q,期望 /api/status(根路径必须原样保留)", gotPath) + } + if gotXFF == "" { + t.Error("未注入 X-Forwarded-For(旧实现缺失项)") + } + if gotProto == "" { + t.Error("未注入 X-Forwarded-Proto(旧实现缺失项)") + } + // 门户 cookie 不得泄漏给上游 + if strings.Contains(gotCookie, "SECRET") { + t.Errorf("门户会话 cookie 被泄漏给上游: %q", gotCookie) + } +} + +// 未声明的子域必须明确报错,而不是静默落到主站页面(那会让用户以为地址对了) +func TestServeProxyHostUnknownLabel(t *testing.T) { + manualProxyRoutes = "" + InvalidateProxyRoutes() + h := NewHandler(nil) + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r.Host = "nosuchplugin.localhost:8080" + if !h.serveProxyHost(rec, r) { + t.Fatal("插件子域(即使未声明)应由反代层应答") + } + if rec.Code != http.StatusNotFound { + t.Fatalf("未声明子域应返回 404,实际 %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "nosuchplugin") { + t.Error("错误信息应含具体标签,便于排错") + } +} + +// 主站 Host 不得被反代层截走 +func TestServeProxyHostMainSiteUntouched(t *testing.T) { + h := NewHandler(nil) + r := httptest.NewRequest("GET", "/", nil) + r.Host = "localhost:8080" + if h.serveProxyHost(httptest.NewRecorder(), r) { + t.Error("主站 Host 不应被反代层处理") + } +} + +// ---- WebSocket 必须显式声明 ---- + +func TestProxyWebSocketRequiresDeclaration(t *testing.T) { + prev := declProvider + SetProxyDeclProvider(func() []proxyDecl { + return []proxyDecl{ + {Plugin: "nws", Name: "ui", Host: "nws", Target: "127.0.0.1:1", WebSocket: false}, + {Plugin: "wsx", Name: "gw", Host: "wsx", Target: "127.0.0.1:1", WebSocket: true}, + } + }) + manualProxyRoutes = "" + InvalidateProxyRoutes() + t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() }) + + h := NewHandler(nil) + // 未声明 ws:升级请求必须被明确拒绝(400),而不是当普通请求透传 + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/ws", nil) + r.Host = "nws.localhost:8080" + r.Header.Set("Upgrade", "websocket") + r.Header.Set("Connection", "Upgrade") + h.serveProxyHost(rec, r) + if rec.Code != http.StatusBadRequest { + t.Errorf("未声明 websocket 的升级请求应 400,实际 %d(body=%s)", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "websocket") { + t.Error("拒绝原因应说明缺 websocket 声明") + } +} + +// ---- 认证可声明:none 直通,homeagent 拦截 ---- + +func TestProxyAuthDeclaration(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer up.Close() + + prev := declProvider + SetProxyDeclProvider(func() []proxyDecl { + return []proxyDecl{ + {Plugin: "open", Name: "ui", Host: "open", Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone}, + {Plugin: "prot", Name: "ui", Host: "prot", Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthHomeAgent}, + {Plugin: "dflt", Name: "ui", Host: "dflt", Target: up.Listener.Addr().String()}, // 空 = 受保护 + } + }) + manualProxyRoutes = "" + InvalidateProxyRoutes() + t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() }) + + h := NewHandler(nil) + + // auth=none:匿名也必须通(设备链路的前提) + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r.Host = "open.localhost:8080" + h.serveProxyHost(rec, r) + if rec.Code != 200 { + t.Errorf("auth=none 应放行匿名请求,实际 %d", rec.Code) + } + + // 显式 homeagent 与默认(空):无凭证必须 401 + for _, host := range []string{"prot.localhost:8080", "dflt.localhost:8080"} { + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r.Host = host + h.serveProxyHost(rec, r) + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s 无凭证应 401,实际 %d", host, rec.Code) + } + var body map[string]string + json.Unmarshal(rec.Body.Bytes(), &body) + if body["hint"] == "" { + t.Errorf("%s 的 401 应给出可操作提示", host) + } + } +} + +// ---- 上游 3xx 必须原样透传(不得由反代跟随)---- + +func TestProxyDoesNotFollowUpstreamRedirect(t *testing.T) { + var hits int + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + if r.URL.Path == "/go" { + // 指向内网敏感地址:旧实现(http.DefaultClient)会跟过去并把 + // 内网 URL 泄给客户端 / 或回 502 + http.Redirect(w, r, "http://127.0.0.1:1/internal-secret", http.StatusFound) + return + } + w.Write([]byte("leaked")) + })) + defer up.Close() + + prev := declProvider + SetProxyDeclProvider(func() []proxyDecl { + return []proxyDecl{{Plugin: "demo", Name: "ui", Host: "demo", Target: up.Listener.Addr().String()}} + }) + manualProxyRoutes = "" + InvalidateProxyRoutes() + t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() }) + + h := NewHandler(proxyTestSettings(t)) + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/go", nil) + r.Host = "demo.localhost:8080" + authHeaders(r) + h.serveProxyHost(rec, r) + + if rec.Code != http.StatusFound { + t.Fatalf("上游 302 应原样透传,实际 %d body=%s", rec.Code, rec.Body.String()) + } + if hits != 1 { + t.Errorf("反代跟随了重定向(上游被打了 %d 次,应为 1)", hits) + } + if strings.Contains(rec.Body.String(), "leaked") { + t.Error("反代跟随重定向后把内网响应体返回给了客户端") + } +} + +// ---- 流式响应必须逐帧下发(不得缓冲到上游关闭)---- +// +// 判据设计说明(这里踩过一次坑,记下来): +// httputil.ReverseProxy 对 **text/event-stream** 与 **ContentLength = -1** +// (chunked)的响应会自动立即 flush,与 FlushInterval 无关。所以只测这两种, +// 判据是**假的**——把 FlushInterval 改成 0(关掉)也照样绿,变异验证抓不到。 +// 真正依赖 FlushInterval 的是「**已知 Content-Length** 但分片慢速下发」的响应 +// (长轮询、带进度的大文件)。本判据专门构造这种响应。 +func TestProxyStreamsIncrementallyWithKnownLength(t *testing.T) { + const total = 32 + release := make(chan struct{}) + started := make(chan struct{}) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 显式 Content-Length:让 ReverseProxy 不走"自动立即 flush"分支, + // 从而真正检验 FlushInterval 设置。 + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(total)) + w.WriteHeader(200) + w.Write([]byte("first-chunk-here")) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + close(started) + <-release // 首片发出后阻塞:若反代缓冲,客户端读不到第一片 + w.Write([]byte(strings.Repeat("x", total-len("first-chunk-here")))) + })) + defer up.Close() + + prev := declProvider + SetProxyDeclProvider(func() []proxyDecl { + return []proxyDecl{{Plugin: "demo", Name: "ui", Host: "demo", Target: up.Listener.Addr().String()}} + }) + manualProxyRoutes = "" + InvalidateProxyRoutes() + t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() }) + + front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + (&proxyTestHandler{h: NewHandler(proxyTestSettings(t))}).ServeHTTP(w, r) + })) + defer front.Close() + + req, _ := http.NewRequest("GET", front.URL+"/stream", nil) + req.Host = "demo.localhost:8080" + req.Header.Set("X-API-Key", "test-api-key") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + <-started + buf := make([]byte, 64) + got := make(chan int, 1) + go func() { + n, _ := resp.Body.Read(buf) + got <- n + }() + select { + case n := <-got: + if n <= 0 { + t.Fatalf("读第一片失败: n=%d", n) + } + if !strings.Contains(string(buf[:n]), "first-chunk-here") { + t.Fatalf("第一片内容异常: %q", string(buf[:n])) + } + case <-timeAfter(2): + close(release) + t.Fatal("反代缓冲了响应:上游首片已 flush 且 Content-Length 已知,客户端却读不到(FlushInterval 未设为立即)") + } + close(release) +} + +// proxyTestHandler 只暴露反代 Host 分发,避免测试依赖 requireWeb 的登录态。 +type proxyTestHandler struct{ h *Handler } + +func (p *proxyTestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if p.h.serveProxyHost(w, r) { + return + } + http.NotFound(w, r) +} + +// ---- 服务入口清单(给前端渲染选项卡)---- + +func TestListProxyServicesIncludesURLAndErrors(t *testing.T) { + prev := declProvider + SetProxyDeclProvider(func() []proxyDecl { + return []proxyDecl{ + {Plugin: "huawei_smarthome", Name: "ui", Host: "huawei-smarthome", Target: "127.0.0.1:12100"}, + {Plugin: "broken", Name: "x", Host: "bad host", Target: "127.0.0.1:1"}, + } + }) + manualProxyRoutes = "" + InvalidateProxyRoutes() + t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() }) + + h := NewHandler(proxyTestSettings(t)) + svcs := h.listProxyServices("http", ":8080") + if len(svcs) != 2 { + t.Fatalf("入口数 = %d,期望 2(含坏条目)", len(svcs)) + } + var good, bad *proxyServiceEntry + for i := range svcs { + if svcs[i].Plugin == "huawei_smarthome" { + good = &svcs[i] + } + if svcs[i].Plugin == "broken" { + bad = &svcs[i] + } + } + if good == nil || !good.OK { + t.Fatalf("合法声明应 OK: %+v", good) + } + // 入口链接必须可直接点击:带 scheme、子域标签、同一端口 + want := "http://huawei-smarthome.localhost:8080" + if good.URL != want { + t.Errorf("服务入口 URL = %q,期望 %q", good.URL, want) + } + if bad == nil || bad.OK || bad.Error == "" { + t.Errorf("坏条目必须带错误且 OK=false: %+v", bad) + } + if bad.URL != "" { + t.Error("坏条目不应给出可点链接") + } +} + +func TestHandleProxyServicesJSON(t *testing.T) { + prev := declProvider + SetProxyDeclProvider(func() []proxyDecl { + return []proxyDecl{{Plugin: "demo", Name: "ui", Host: "demo", Target: "127.0.0.1:1"}} + }) + manualProxyRoutes = "" + InvalidateProxyRoutes() + t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() }) + + h := NewHandler(proxyTestSettings(t)) + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/api/v1/proxy/services", nil) + r.Host = "localhost:8080" + authHeaders(r) + h.handleProxyServices(rec, r) + if rec.Code != 200 { + t.Fatalf("code=%d", rec.Code) + } + var out struct { + Services []proxyServiceEntry `json:"services"` + BaseDomain string `json:"base_domain"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out.BaseDomain != "localhost" { + t.Errorf("base_domain = %q,期望 localhost(零配置默认)", out.BaseDomain) + } + // 上游 127.0.0.1:1 不可达 → 必须如实标出不 OK,而不是假装可用 + if len(out.Services) != 1 || out.Services[0].OK { + t.Errorf("不可达上游应标 OK=false: %+v", out.Services) + } +} + +// 服务入口链接在 https 反代场景下必须换成 https(否则远程点不开) +func TestProxySchemeAndPortRemote(t *testing.T) { + h := &Handler{} + r := httptest.NewRequest("GET", "/", nil) + r.Host = "portal.example.com" + r.Header.Set("X-Forwarded-Proto", "https") + r.Header.Set("X-Forwarded-Host", "portal.example.com") + scheme, port := h.proxySchemeAndPort(r) + if scheme != "https" { + t.Errorf("scheme = %q,期望 https", scheme) + } + if port != "" { + t.Errorf("https 默认端口应为空(省略 443),实际 %q", port) + } + // 本机 http:8080 + r2 := httptest.NewRequest("GET", "/", nil) + r2.Host = "localhost:8080" + s2, p2 := h.proxySchemeAndPort(r2) + if s2 != "http" || p2 != ":8080" { + t.Errorf("本机应为 http/:8080,实际 %q/%q", s2, p2) + } +} + +// timeAfter 是 time.After 的薄封装(测试里多处用,集中一处便于调整)。 +func timeAfter(seconds int) <-chan time.Time { + return time.After(time.Duration(seconds) * time.Second) +} + +// ---- 端到端:模拟真实插件 UI 的「根绝对路径」行为 ---- +// +// 这是选 Host 路由而非路径前缀的**核心理由**,必须有判据钉住: +// 插件前端写 `fetch('/api/status')`,经 Host 反代后必须打到**上游**的 +// /api/status,而不是 webui 自己的 /api/v1/*。若哪天改成路径前缀方案, +// 这条会红。 +func TestProxyPreservesRootAbsolutePathsLikeRealPluginUI(t *testing.T) { + var got []string + // 伪造一个「插件 UI + API」上游:页面里含根绝对路径的 fetch, + // /api/status 返回插件自己的数据(与门户 /api/v1/status 完全不同)。 + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = append(got, r.URL.Path) + switch r.URL.Path { + case "/": + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(``)) + case "/api/status": + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"plugin":"huawei","devices":3}`)) + default: + http.NotFound(w, r) + } + })) + defer up.Close() + + prev := declProvider + SetProxyDeclProvider(func() []proxyDecl { + return []proxyDecl{{Plugin: "huawei_smarthome", Name: "ui", Host: "huawei-smarthome", Target: up.Listener.Addr().String()}} + }) + manualProxyRoutes = "" + InvalidateProxyRoutes() + t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() }) + + h := NewHandler(proxyTestSettings(t)) + + // 1) 打开插件首页 + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r.Host = "huawei-smarthome.localhost:8080" + authHeaders(r) + if !h.serveProxyHost(rec, r) { + t.Fatal("应被反代处理") + } + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "fetch('/api/status')") { + t.Fatalf("插件首页未透传: code=%d", rec.Code) + } + + // 2) 页面里的根绝对路径请求 → 必须打到上游 plugins 的 /api/status + rec2 := httptest.NewRecorder() + r2 := httptest.NewRequest("GET", "/api/status", nil) + r2.Host = "huawei-smarthome.localhost:8080" + authHeaders(r2) + if !h.serveProxyHost(rec2, r2) { + t.Fatal("插件子域下的 /api/status 应被反代处理") + } + var data map[string]interface{} + if err := json.Unmarshal(rec2.Body.Bytes(), &data); err != nil { + t.Fatalf("上游 JSON 未透传: %s", rec2.Body.String()) + } + if data["plugin"] != "huawei" { + t.Fatalf("根绝对路径被错路由了:拿到的不是插件数据而是 %v", data) + } + // 3) 路径必须原样到上游(前缀剥除在这里是错的) + if len(got) == 0 || got[len(got)-1] != "/api/status" { + t.Errorf("上游收到的路径 = %v,末项应为 /api/status", got) + } +} + +// ---- 自动发现:读插件目录里的 plugin.json ---- +// +// readPluginProxyDecls 是「自动发现」的核心,必须单独钉住:它决定了用户 +// 是否需要手填端口。 +func TestReadPluginProxyDecls(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) { + d := filepath.Join(dir, name) + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, "plugin.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + // ① 显式 host + ws + auth + write("a", `{"name":"a","entry":"plugin.bin","proxies":[ + {"name":"ui","host":"aaa","target":"127.0.0.1:12100"}, + {"name":"gw","host":"aaa-gw","target":"127.0.0.1:9890","websocket":true,"auth":"none"}]}`) + // ② 省略 host → 由插件名派生(下划线转连字符) + write("b_plugin", `{"name":"b_plugin","entry":"plugin.bin","proxies":[{"target":"127.0.0.1:9999"}]}`) + // ③ 没有 proxies 字段 → 不产出 + write("c", `{"name":"c","entry":"plugin.bin"}`) + // ④ 非插件目录(无 plugin.json)→ 跳过,不得 panic + if err := os.MkdirAll(filepath.Join(dir, "notaplugin"), 0o755); err != nil { + t.Fatal(err) + } + // ⑤ 坏 JSON → 跳过该插件,不影响其它 + write("d", `{ this is not json`) + + decls := readPluginProxyDecls(dir) + if len(decls) != 3 { + t.Fatalf("发现 %d 条声明,期望 3:%+v", len(decls), decls) + } + byHost := map[string]proxyDecl{} + for _, d := range decls { + byHost[d.Host] = d + } + if d, ok := byHost["aaa"]; !ok || d.Target != "127.0.0.1:12100" || d.WebSocket { + t.Errorf("条目 aaa 不对: %+v", d) + } + if d, ok := byHost["aaa-gw"]; !ok || !d.WebSocket || d.Auth != "none" { + t.Errorf("条目 aaa-gw 未带上 ws/auth: %+v", d) + } + // 省略 host 的必须由插件名派生为合法 DNS label + if d, ok := byHost["b-plugin"]; !ok { + t.Errorf("省略 host 的声明未按插件名派生(期望 b-plugin): %+v", byHost) + } else if !sdk.ValidProxyHostLabel(d.Host) { + t.Errorf("派生的 host 不合法: %q", d.Host) + } + // 空目录不得 panic、返回空 + if got := readPluginProxyDecls(""); len(got) != 0 { + t.Error("空目录应返回空") + } + if got := readPluginProxyDecls(filepath.Join(dir, "does-not-exist")); len(got) != 0 { + t.Error("不存在的目录应返回空而不是 panic") + } +} + +// 自动发现的声明必须真的能路由(打通「扫目录 → 建表 → 转发」整条链) +func TestAutoDiscoveryRoutesEndToEnd(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("discovered-upstream")) + })) + defer up.Close() + + dir := t.TempDir() + d := filepath.Join(dir, "huawei_smarthome") + os.MkdirAll(d, 0o755) + os.WriteFile(filepath.Join(d, "plugin.json"), []byte(`{ + "name":"huawei_smarthome","entry":"plugin.bin", + "proxies":[{"name":"ui","target":"`+up.Listener.Addr().String()+`","auth":"none"}]}`), 0o644) + + // 用真实的自动发现回调(而不是注入假声明)——这才测到发现链路 + prev := declProvider + SetProxyDeclProvider(func() []proxyDecl { return readPluginProxyDecls(dir) }) + manualProxyRoutes = "" + InvalidateProxyRoutes() + t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() }) + + h := NewHandler(nil) + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + // 标签由插件名派生:huawei_smarthome → huawei-smarthome + r.Host = "huawei-smarthome.localhost:8080" + if !h.serveProxyHost(rec, r) { + t.Fatal("自动发现的声明应被反代处理") + } + if rec.Code != 200 || rec.Body.String() != "discovered-upstream" { + t.Fatalf("自动发现的声明未生效: code=%d body=%q", rec.Code, rec.Body.String()) + } +} diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index 4181c15..3fd01e7 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -320,6 +320,9 @@ type SDKConfig struct { 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) }) if cfg.IOManager != nil { base.SetIOInjector(ioAdapter{iom: cfg.IOManager}) } diff --git a/internal/sdk/proxy.go b/internal/sdk/proxy.go new file mode 100644 index 0000000..249c957 --- /dev/null +++ b/internal/sdk/proxy.go @@ -0,0 +1,109 @@ +package sdk + +import ( + "strings" + "sync" + + pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +// 反代声明在 **公开 SDK** 里定义(`pubsdk`),内核侧只是别名转发。 +// +// 为什么放公开 SDK 而不是内核实现在:声明是**插件作者直接书写的契约** +// (plugin.json 的 proxies 字段),必须与 SDK 文档、hmapdev 工具链用同一套 +// 定义与校验,否则插件作者本地通过、内核拒绝,或反之。 +// +// 这与 ConfigDef 等信息完全同构——公开面定义契约,内核面实现行为。 + +// ProxyDecl 是一条反代声明(见 pubsdk.ProxyDecl 的完整文档)。 +type ProxyDecl = pubsdk.ProxyDecl + +// 生效的鉴权模式取值。 +const ( + // ProxyAuthHomeAgent:由 HomeAgent 统一保护(门户会话或 X-API-Key)。 + ProxyAuthHomeAgent = pubsdk.ProxyAuthHomeAgent + // ProxyAuthNone:不经 HomeAgent 鉴权,信任上游自身鉴权。 + ProxyAuthNone = pubsdk.ProxyAuthNone +) + +// ValidProxyAuth 校验鉴权模式取值(空串合法,等价 ProxyAuthHomeAgent)。 +func ValidProxyAuth(auth string) bool { return pubsdk.ValidProxyAuth(auth) } + +// EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHomeAgent)。 +func EffectiveProxyAuth(auth string) string { return pubsdk.EffectiveProxyAuth(auth) } + +// ValidProxyHostLabel 校验子域标签是否合法(DNS label 规则)。 +func ValidProxyHostLabel(label string) bool { return pubsdk.ValidProxyHostLabel(label) } + +// NormalizeProxyHost 由插件名派生默认的子域标签。 +func NormalizeProxyHost(pluginName string) string { return pubsdk.NormalizeProxyHost(pluginName) } + +// ValidateProxyDecl 校验一条声明,返回人类可读的错误(合法时为空)。 +func ValidateProxyDecl(d ProxyDecl) string { return pubsdk.ValidateProxyDecl(d) } + +// ---- 内置插件反代声明的运行期登记表 ---- + +// 为什么需要它:外部插件的声明在 plugin.json 里,可以扫目录发现;但**内置** +// 插件编译进内核、没有插件目录,靠扫盘永远发现不了自己的服务——而设备网关 +// (remotedevice)正是内置的,且最需要被反代出去。两种来源互补。 +var ( + builtinProxyMu sync.RWMutex + builtinProxyDecls = map[string][]ProxyDecl{} + builtinProxyVer int64 +) + +// DeclareBuiltinProxy 登记一个内置插件的服务声明(由 DeclareProxy 转发)。 +func DeclareBuiltinProxy(plugin string, d ProxyDecl) { + if plugin == "" || strings.TrimSpace(d.Target) == "" { + return + } + builtinProxyMu.Lock() + defer builtinProxyMu.Unlock() + // 同一插件同一声明名重复登记(如自动重启后再次 Start)视为刷新,不重复累积。 + name := d.Name + if name == "" { + name = "service" + d.Name = name + } + list := builtinProxyDecls[plugin] + for i := range list { + if list[i].Name == name { + list[i] = d + builtinProxyVer++ + return + } + } + builtinProxyDecls[plugin] = append(list, d) + builtinProxyVer++ +} + +// ClearBuiltinProxyDecls 清除某插件的声明(插件停止/卸载时调用)。 +func ClearBuiltinProxyDecls(plugin string) { + builtinProxyMu.Lock() + defer builtinProxyMu.Unlock() + if _, ok := builtinProxyDecls[plugin]; ok { + delete(builtinProxyDecls, plugin) + builtinProxyVer++ + } +} + +// BuiltinProxyDecls 返回内置插件声明的快照(plugin → decls)。 +func BuiltinProxyDecls() map[string][]ProxyDecl { + builtinProxyMu.RLock() + defer builtinProxyMu.RUnlock() + out := make(map[string][]ProxyDecl, len(builtinProxyDecls)) + for k, v := range builtinProxyDecls { + cp := make([]ProxyDecl, len(v)) + copy(cp, v) + out[k] = cp + } + return out +} + +// BuiltinProxyVersion 是声明表的版本号。调用方(webui 反代层)据它判断 +// 缓存的路由表是否过期——比每次请求重新聚合一遍便宜得多。 +func BuiltinProxyVersion() int64 { + builtinProxyMu.RLock() + defer builtinProxyMu.RUnlock() + return builtinProxyVer +} diff --git a/internal/sdk/proxy_test.go b/internal/sdk/proxy_test.go new file mode 100644 index 0000000..3f6ed00 --- /dev/null +++ b/internal/sdk/proxy_test.go @@ -0,0 +1,55 @@ +package sdk + +import "testing" + +// 内置插件的运行期声明必须真的被登记、可枚举、可清除—— +// remotedevice(内置、无 plugin.json)就靠这条通道。 +func TestBuiltinProxyDeclRegistry(t *testing.T) { + const p = "test_builtin_proxy" + ClearBuiltinProxyDecls(p) + defer ClearBuiltinProxyDecls(p) + + before := BuiltinProxyVersion() + DeclareBuiltinProxy(p, ProxyDecl{Name: "gw", Host: "devices", Target: "127.0.0.1:9890", WebSocket: true, Auth: ProxyAuthNone}) + if BuiltinProxyVersion() == before { + t.Error("登记后版本号应递增(反代层靠它判断缓存失效)") + } + + got := BuiltinProxyDecls() + list := got[p] + if len(list) != 1 { + t.Fatalf("登记了 %d 条,期望 1: %+v", len(list), got) + } + if !list[0].WebSocket || list[0].Auth != ProxyAuthNone || list[0].Host != "devices" { + t.Errorf("声明内容不对: %+v", list[0]) + } + + // 重复登记同名(如自动重启后再次 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 { + t.Errorf("重复登记应为刷新,实际累积成 %d 条", len(l)) + } + + // 空 target 必须被拒(不声明的默认就是不被反代,空声明更不该登记) + DeclareBuiltinProxy(p, ProxyDecl{Name: "bad", Target: " "}) + if l := BuiltinProxyDecls()[p]; len(l) != 1 { + t.Errorf("空 target 不应被登记,实际 %d 条", len(l)) + } + + ClearBuiltinProxyDecls(p) + if _, ok := BuiltinProxyDecls()[p]; ok { + t.Error("清除后不应还有该插件的声明") + } +} + +// DeclareProxy 必须把声明转发给内核注入的收集回调。 +func TestDeclareProxyForwards(t *testing.T) { + var got []ProxyDecl + ps := New("demo", SDKConfig{}) + ps.SetProxyDeclarer(func(d ProxyDecl) { got = append(got, d) }) + + ps.DeclareProxy(ProxyDecl{Name: "ui", Host: "demo", Target: "127.0.0.1:12100", WebSocket: false, Auth: ProxyAuthNone}) + if len(got) != 1 || got[0].Host != "demo" || got[0].Auth != ProxyAuthNone { + t.Fatalf("声明未转发到收集回调: %+v", got) + } +} diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go index ce1311d..a71b890 100644 --- a/third_party/homeagent-sdk/sdk/plugin.go +++ b/third_party/homeagent-sdk/sdk/plugin.go @@ -353,6 +353,11 @@ type PluginSDK struct { events EventSubscriber plgMgr PluginMgrAPI + // proxyDecl 是反代声明的收集回调(内置插件经 DeclareProxy 声明服务)。 + // 与上面的 API 字段同受 apiMu 保护——写方是内核注入,读方是插件 Start + // 起的 goroutine。 + proxyDecl ProxyDeclarer + // apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。 // // 这些字段的写方与读方天然跨 goroutine: diff --git a/third_party/homeagent-sdk/sdk/proxy.go b/third_party/homeagent-sdk/sdk/proxy.go new file mode 100644 index 0000000..1ef1668 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/proxy.go @@ -0,0 +1,249 @@ +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) + } +} diff --git a/third_party/homeagent-sdk/sdk/proxy_test.go b/third_party/homeagent-sdk/sdk/proxy_test.go new file mode 100644 index 0000000..5199686 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/proxy_test.go @@ -0,0 +1,111 @@ +package sdk + +import "testing" + +func TestProxyAuthDefaultsToHomeAgent(t *testing.T) { + // 空串必须归一化为「HomeAgent 统一保护」——这是安全默认。 + // 若哪天有人把默认改成 none,这条会立刻红。 + if got := EffectiveProxyAuth(""); got != ProxyAuthHomeAgent { + t.Fatalf("空 auth 应归一化为 %q,实际 %q", ProxyAuthHomeAgent, got) + } + if got := EffectiveProxyAuth(ProxyAuthNone); got != ProxyAuthNone { + t.Fatalf("显式 none 应保持 none,实际 %q", got) + } + for _, ok := range []string{"", ProxyAuthHomeAgent, ProxyAuthNone} { + if !ValidProxyAuth(ok) { + t.Errorf("%q 应合法", ok) + } + } + for _, bad := range []string{"nope", "HOMEAGENT", "None", "true"} { + if ValidProxyAuth(bad) { + t.Errorf("%q 应非法", bad) + } + } +} + +func TestValidProxyHostLabel(t *testing.T) { + legit := []string{"huawei", "a", "a-b", "abc123", "0", "x" + string(make([]byte, 0)) + "yz"} + for _, s := range legit { + if !ValidProxyHostLabel(s) { + t.Errorf("%q 应为合法 label", s) + } + } + bad := []string{ + "", "-a", "a-", "-", "a_b", "a.b", "A", "aB", "a b", + "a/b", "a:b", string(make([]byte, 64)), // 超长 63 + } + for _, s := range bad { + if ValidProxyHostLabel(s) { + t.Errorf("%q 应为非法 label", s) + } + } + // 边界:恰好 63 合法,64 非法 + l63 := "" + for i := 0; i < 63; i++ { + l63 += "a" + } + if !ValidProxyHostLabel(l63) { + t.Error("63 字符应为合法 label") + } + if ValidProxyHostLabel(l63 + "a") { + t.Error("64 字符应为非法 label") + } +} + +func TestNormalizeProxyHost(t *testing.T) { + cases := map[string]string{ + "huawei_smarthome": "huawei-smarthome", // 下划线不是合法 DNS label + "webui": "webui", + "UPPER_Case": "upper-case", + "a__b": "a--b", + "__x__": "x", + "---": "plugin", // 全非法 → 保守回退 + "": "plugin", + "a.b.c": "abc", + } + for in, want := range cases { + if got := NormalizeProxyHost(in); got != want { + t.Errorf("NormalizeProxyHost(%q) = %q,期望 %q", in, got, want) + } + } + // 归一化结果必须自身合法(产物自洽) + for _, in := range []string{"huawei_smarthome", "UPPER_Case", "__x__", "a.b.c", "非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字"} { + if got := NormalizeProxyHost(in); !ValidProxyHostLabel(got) { + t.Errorf("NormalizeProxyHost(%q) = %q 不合法", in, got) + } + } +} + +func TestValidateProxyDecl(t *testing.T) { + valid := []ProxyDecl{ + {Target: "127.0.0.1:12100"}, + {Target: "http://127.0.0.1:12100"}, + {Target: "127.0.0.1:12100", Host: "huawei"}, + {Target: "127.0.0.1:12100", Auth: ProxyAuthNone}, + {Target: "127.0.0.1:12100", Auth: ProxyAuthHomeAgent, WebSocket: true}, + {Target: "127.0.0.1:3000/base", Host: "x"}, + {Target: "https://example.com", Host: "ext"}, // 远程上游也允许(由 auth 决定安全性) + } + for _, d := range valid { + if msg := ValidateProxyDecl(d); msg != "" { + t.Errorf("%+v 应合法,却报: %s", d, msg) + } + } + + bad := []ProxyDecl{ + {}, // 无 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: "127.0.0.1:notaport"}, // 端口非数字 + } + for _, d := range bad { + if msg := ValidateProxyDecl(d); msg == "" { + t.Errorf("%+v 应被拒绝,却通过了", d) + } + } +}