Files
HomeAgent/internal/plugins/webui/proxy_test.go
JianFeeeee 1e58af79d3 feat(webui): 通用反向代理 —— 插件声明服务,HomeAgent 按子域反代出去
用户要求:外部只装 HomeAgent 即可使用自带反代能力;用户只需穿透一个
webui 端口就能访问所有内部插件服务;认证与 WebSocket 支持都作为插件
的可声明项;插件 UI 要有可直接点击的入口。

实测 huawei_smarthome 插件的前端用**根绝对路径**(api('/api/status') →
fetch('/api/status'))。挂在 /p/<name>/ 这类路径前缀下,这些请求会打到
HomeAgent 自己的 /api/status —— 静默错路由;做 HTML/JS 内容重写对拼进
JS 字符串的绝对路径只是"按概率能用",会产生"页面能开、某个按钮就坏"的
静默故障。子域路由下根路径天然正确,**插件前端零改动**。

且它天然匹配"只穿透一个端口":webui 监听 0.0.0.0:8080 按 Host 分发,
外层 frp 单端口 TCP 隧道**一行都不用改**。

默认基座 localhost:RFC 6761 规定 *.localhost 强制解析到 loopback,
现代浏览器原生支持 ⇒ <标签>.localhost:8080 **零配置可用**,不需要 DNS、
证书、/etc/hosts。远程部署改 base_domain 即可。

- 外部插件 → plugin.json 的 proxies(静态可发现:插件没起来也能报
  "声明了 ui 但目标不可达",而不是静默 404)
- 内置插件 → s.DeclareProxy()(remotedevice 是内置的、没有 plugin.json,
  却最需要被反代出去)

反代层在 webui 侧读清单:webui 已能拿到插件目录(PluginManager.PluginDir),
因此**无需给内核接口加方法**。manifest 解析忽略未知字段,加 proxies 对
"旧内核读新插件"与"新内核读旧插件"都无害。

新增 sdk/ProxyDecl 与配套校验(ValidProxyAuth / ValidProxyHostLabel /
NormalizeProxyHost / ValidateProxyDecl);新增运行期 ProxyDeclarer 通道。
hmapdev 的 writePluginJSON 是**白名单 map 重建**——不同步加字段会让声明
被打包静默丢弃(插件作者本地正常、装上去失效),因此 PlgConfig 与
writePluginJSON 同时加,并在打包前校验声明(插件作者本地就能发现写错)。

auth=homeagent(默认,安全的默认):门户会话 / X-API-Key / ?__token=;
auth=none:信任上游自身鉴权,供设备与嵌入式客户端使用——它们不可能持有
浏览器会话,强制走门户鉴权会把设备链路挡死。remotedevice 声明 none,
因为它自身用 ws_token 强制校验。

未声明时升级请求**明确拒绝**(400 + 原因),而不是静默降级成普通请求
(后者表现为前端不断重连、日志看不出原因)。

1. 不跟随上游 3xx:旧实现用 http.DefaultClient(默认跟最多 10 跳),
   上游 302 到内网地址时反代自己跟过去、失败回 502 并把内网 URL 泄给
   客户端。httputil.ReverseProxy 默认不跟随,3xx 原样透传。
2. 逐帧 flush:旧实现 io.Copy 导致上游流式响应被缓冲到上游关闭才下发
   (实测 3 帧 200ms 间隔的流,客户端在 +600ms 一次性收到全部)。
   设 FlushInterval=-1。

另补齐 X-Forwarded-For/Host/Proto(旧实现完全不注入,上游无法判断真实
来源),并剥掉上游 Set-Cookie 的 Domain(防止插件 cookie 打到主门户域)。

插件页新增「服务入口」卡片:列出全部被反代的插件服务(含被拒条目与
不可达原因),点「打开」直接访问。链接带 ?__token=<api_key>,因为子域
与门户不同源、浏览器不会自动带会话 cookie。

webui +35 条、SDK +4 条、工具链 +4 条。关键几条:
- 根绝对路径必须原样到上游(选 Host 路由的核心理由)
- 上游 302 必须原样透传、且反代不得跟随(旧缺陷)
- 已知 Content-Length 的慢速响应必须逐帧到达(**这条经过变异验证**:
  把 FlushInterval 改回 0 后判据挂死 → FAIL,还原后回绿。
  说明:最初写的 SSE/chunked 版本是假判据——ReverseProxy 对
  text/event-stream 与 ContentLength=-1 会自动立即 flush,与
  FlushInterval 无关,变异抓不到,已改正)
- 子域标签冲突不得静默覆盖(后者保留可见并带原因)
- 非法声明不进路由但必须可见(配置页要能看到原因)
- 未声明 websocket 的升级请求必须 400
- auth 逐条生效:none 放行匿名、homeagent 与默认档 401 且给可操作提示
- 自动发现:显式 host 不得被自动编号覆盖(**测试抓到的真 bug**:
  remotedevice 声明的 "devices" 会被改成 "devices-2" 而静默失效)
- 真实端到端:生产实例 huawei_smarthome 的 UI(9444 字节)与其
  /api/status 经反代正确透传

go build ./... 通过;相关包全量测试通过。
internal/plugin/proc 的 TestStreaming_PublishLatencyFlatAcrossSubscribers
是**预存在的不稳定测试**(同一份代码 10 次跑 9 过 1 败,且本改动完全
未触及该包),非本次引入。
2026-09-26 13:49:59 +08:00

716 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package 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(`<script>fetch('/api/status').then(r=>r.json())</script>`))
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())
}
}