Files
HomeAgent/internal/plugins/webui/proxy_test.go
JianFeeeee 7f5bf1670f feat(clients): 设备桥自动链接改用服务端发现 + 路径挂载(无 DNS 依赖)
配套 webui 反代改造:网关现在可由 HomeAgent 反代出去,客户端不能再靠
「门户地址同 host 拼 /api/v1/device/ws」猜地址——基域名与子域标签都是
**服务端配置**,客户端无从得知。

## 服务端:/api/v1/device/gateway 发现端点

客户端问「网关在哪」是唯一不会漂移的做法:子域标签可改(插件声明)、
基域名可改(webui.base_domain)、实例可换形态,客户端都不用跟着改。

⚠️ **不返回设备令牌**:本端点用门户凭证鉴权,而设备令牌能执行设备命令;
把令牌塞进来等于「门户只读凭证 → 设备执行权」的越权。令牌仍由客户端
自配。已有判据钉住「不得泄漏凭证字段」。

## ★ 实测发现:*.localhost 只有浏览器能解析

这是本轮最重要的发现,直接决定了设计:

| 环境 | devices.localhost 解析 |
|---|---|
| 浏览器 | ✓(RFC 6761 内置) |
| curl | ✓(内置特例) |
| getent / Go / Node | ✗(系统 nsswitch 是 files,dns,无 nss-myhostname) |

设备客户端(waiter / GUI 主进程 / 嵌入式固件)用的正是系统解析器。
实测 waiter 报「lookup devices.localhost on 192.168.2.1:53」。

因此**两处**设计变更:
1. 发现端点同时返回两种形态,并标 preferred:
   - url(子域)—— 浏览器用
   - url_portal(门户同源,同一 host、同一端口,走路径挂载)—— 非浏览器用,
     无任何 DNS 依赖
2. SDK 的 ProxyDecl 新增 **Path**(路径挂载前缀):让同一服务同时挂到
   门户自身 host 的路径下。remotedevice 声明 Path="/api/v1/device",
   设备客户端因此能沿用**它已硬编码的路径**,不需要知道反代存在。

路径挂载语义:请求路径**原样保留**(不剥前缀),上游按真实路径注册即可。
边界卡在路径分隔符上(/api/v1/device 不匹配 /api/v1/devicefoo)。

## 客户端

- **waiter**:新增 discoverGateway(),仅在用户配了门户地址时尝试,失败回退
  自配地址(老版本 HomeAgent 无该端点)。抽出 normalizeGateway() 纯函数,
  显式钉住「已带子域/完整端点的地址不得被改写」。
- **GUI**:renderer 新增 loadDiscoveredGateway(),renderDeviceChannel 优先用
  发现值、回退旧口径。顺带修掉此前插入函数时 anchor 不匹配导致调用点
  找不到定义的问题。
- **鸿蒙**:discoverGateway() + resolveGatewayUrl(),优先 url_portal。
- 三者都**优先 url_portal**(system resolver 的现实约束)。

## 遗留路由鉴权修正

`/api/v1/device/` 的旧路径反代原被 requireAPI 包裹 —— 但其调用方是设备
(带设备令牌而非门户凭证),套上门户鉴权会把它们全挡在 401(**真实实测**:
waiter 经此路径升级握手 401)。去掉这层包装,鉴权交给上游 remotedevice
自己的 requireToken,安全性不降级。

## 判据

webui +6 条、waiter +7 条。

★ 其中一条是**真实回归**:/api/v1/device/gateway 曾被 Path="/api/v1/device"
的路径挂载接走(那服务 auth=none),于是发现请求被转给上游、回 401,
客户端再也发现不到网关。修法是发现端点先于路径挂载判定,并补判据
(走完整生产链,同时确认同前缀的真实设备路径仍归反代)。

## 真实验收(隔离实例,命名 netns + 独立 data + 18080)

真 waiter 客户端 + 真 remotedevice 网关:
  device gateway discovered: ws://127.0.0.1:18080/api/v1/device/ws
  device bridge active: waiter-mainserver authorized=true
  hello_ack / bind_ack 均经反代往返成功

说明:`bind_ack device=<nil>` 与在线列表为空的现象,**直连 9890 绕开反代
完全一致复现**,属 remotedevice 与 waiter 之间既有的握手细节,与本次
反代改造无关(反代侧职责已证:连接建立 + 双向帧往返都通)。
2026-09-26 13:49:59 +08:00

1199 lines
42 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", "localhost: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())
}
}
// ---- 凭证头按 auth 区分:真实端到端发现过这个 bug ----
//
// auth=none 的路由,凭证是给**上游**的(设备网关的接入令牌走 X-API-Key),
// 必须原样转发;无条件剥掉会让设备链路全部 401。
// auth=homeagent 的路由,凭证是给门户的,绝不能泄漏给上游。
//
// 说明:这条判据是**事后补的**。此前的单测用「不校验凭证的假上游」,
// 抓不到这个 bug;是隔离实例上跑真实 remotedevice(真令牌、真校验)
// 才发现「直连 200、经反代 401」。
func TestProxyCredentialHeadersDependOnAuth(t *testing.T) {
type probe struct {
cookie string
apiKey string
bearer string
}
got := make(chan probe, 4)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got <- probe{
cookie: r.Header.Get("Cookie"),
apiKey: r.Header.Get("X-API-Key"),
bearer: r.Header.Get("Authorization"),
}
w.Write([]byte("ok"))
}))
defer up.Close()
prev := declProvider
SetProxyDeclProvider(func() []proxyDecl {
return []proxyDecl{
{Plugin: "dev", Name: "gw", Host: "dev", Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone},
{Plugin: "ui", Name: "ui", Host: "ui", Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthHomeAgent},
}
})
manualProxyRoutes = ""
InvalidateProxyRoutes()
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
h := NewHandler(proxyTestSettings(t))
call := func(host, apiKey string) probe {
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Host = host
// 同时带上三样:受保护路由要靠门户 key 过鉴权,而我们要断言的是
// **这三样都不该到上游**;不受保护路由则用设备令牌,且必须到上游。
r.Header.Set("X-API-Key", apiKey)
r.Header.Set("Authorization", "Bearer UPSTREAM-BEARER")
r.Header.Set("Cookie", "homeagent_session=PORTAL")
h.serveProxyHost(rec, r)
select {
case p := <-got:
return p
case <-time.After(3 * time.Second):
t.Fatalf("%s 未到达上游", host)
return probe{}
}
}
// auth=none:上游自己的令牌必须保留
// auth=none:用的就是设备令牌(它同时也是发给上游的凭证)
p := call("dev.localhost:8080", "UPSTREAM-DEVICE-TOKEN")
if p.apiKey != "UPSTREAM-DEVICE-TOKEN" {
t.Errorf("auth=none 时 X-API-Key 必须转发给上游(设备令牌),实际 %q", p.apiKey)
}
if p.bearer != "Bearer UPSTREAM-BEARER" {
t.Errorf("auth=none 时 Authorization 应转发,实际 %q", p.bearer)
}
// auth=homeagent:门户凭证绝不能泄漏
// auth=homeagent:用门户 key 通过鉴权,再看它有没有被转发出去
q := call("ui.localhost:8080", "test-api-key")
if q.apiKey != "" {
t.Errorf("auth=homeagent 时 X-API-Key(门户密钥)泄漏给上游: %q", q.apiKey)
}
if strings.Contains(q.cookie, "PORTAL") {
t.Errorf("auth=homeagent 时门户 cookie 泄漏给上游: %q", q.cookie)
}
if q.bearer != "" {
t.Errorf("auth=homeagent 时 Authorization 泄漏给上游: %q", q.bearer)
}
}
// ---- Host 分发必须先于门户路由判定 ----
//
// 真实端到端踩到的坑:插件子域上的路径若与门户某条更具体的路由同名
// (例如 /api/v1/device/online),会被那条**面向门户**的路由截走——
// 表现为 401,且响应体是门户的 JSON 而非上游的响应。
//
// 本判据经由完整 RegisterRoutes(而不是直接调 serveProxyHost)验证:
// 走一遍真实 mux,确认插件子域被 Host 分发接住。
func TestProxyHostTakesPrecedenceOverPortalRoutes(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("UPSTREAM-ANSWER"))
}))
defer up.Close()
prev := declProvider
SetProxyDeclProvider(func() []proxyDecl {
return []proxyDecl{{
Plugin: "dev", Name: "gw", Host: "dev",
Target: up.Listener.Addr().String(), Auth: sdk.ProxyAuthNone,
}}
})
manualProxyRoutes = ""
InvalidateProxyRoutes()
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
h := NewHandler(proxyTestSettings(t))
h.RegisterRoutes(http.NewServeMux())
// 路径刻意选一个门户也注册了更具体模式的路径。
// 若 Host 分发没生效,会被 /api/v1/device/ 的 requireAPI 接走 → 401 JSON。
for _, p := range []string{"/api/v1/device/online", "/api/v1/status", "/api/v1/plugins/", "/"} {
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", p, nil)
r.Host = "dev.localhost:8080"
h.Handler().ServeHTTP(rec, r)
if rec.Code != 200 || rec.Body.String() != "UPSTREAM-ANSWER" {
t.Errorf("插件子域 %s 被门户路由截走了:code=%d body=%q(应为上游响应)",
p, rec.Code, rec.Body.String())
}
}
// 主门户 Host 上,同样的路径必须仍走门户自己的路由(不能被反代吞掉)
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/api/v1/status", nil)
r.Host = "localhost:8080"
r.Header.Set("X-API-Key", "test-api-key")
h.Handler().ServeHTTP(rec, r)
if rec.Code != 200 {
t.Fatalf("门户自身的 /api/v1/status 不可用:%d", rec.Code)
}
var st map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil {
t.Fatalf("门户 /api/v1/status 返回的不是门户 JSON(被反代吞了?): %s", rec.Body.String())
}
if st["status"] != "running" {
t.Errorf("门户 /api/v1/status 返回异常: %v", st)
}
}
// ---- 设备网关发现:客户端自动链接的权威来源 ----
//
// 改造后网关在 devices.<基域名>,而客户端无从知道基域名与子域标签。
// 让服务端回答「网关在哪」是唯一不漂移的做法。
func TestDeviceGatewayDiscovery(t *testing.T) {
prev := declProvider
SetProxyDeclProvider(func() []proxyDecl {
return []proxyDecl{{
Plugin: "remotedevice", Name: "gateway", Host: "devices",
Target: "127.0.0.1:9890", WebSocket: true, Auth: sdk.ProxyAuthNone,
}}
})
manualProxyRoutes = ""
InvalidateProxyRoutes()
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
h := NewHandler(proxyTestSettings(t))
// 本机 http:8080
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/api/v1/device/gateway", nil)
r.Host = "localhost:8080"
h.handleDeviceGatewayDiscovery(rec, r)
var got struct {
Available bool `json:"available"`
URL string `json:"url"`
Host string `json:"host"`
Base string `json:"base_domain"`
Auth string `json:"auth"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Available {
t.Fatalf("应报告网关可用: %s", rec.Body.String())
}
if got.URL != "ws://devices.localhost:8080/api/v1/device/ws" {
t.Errorf("url = %q,期望 ws://devices.localhost:8080/api/v1/device/ws", got.URL)
}
if got.Host != "devices.localhost" {
t.Errorf("host = %q", got.Host)
}
if got.Auth != sdk.ProxyAuthNone {
t.Errorf("auth = %q", got.Auth)
}
// ★ 门户同源形态必须一并给出:*.localhost 只有浏览器能解析,
// 设备客户端走系统解析器会失败(实测:getent/Go 均解析不到)。
var full struct {
URLPortal string `json:"url_portal"`
Preferred string `json:"preferred"`
}
json.Unmarshal(rec.Body.Bytes(), &full)
if full.URLPortal != "ws://localhost:8080/api/v1/device/ws" {
t.Errorf("url_portal = %q,期望门户同源形态 ws://localhost:8080/api/v1/device/ws", full.URLPortal)
}
if full.Preferred != "url_portal" {
t.Errorf("preferred = %q,非浏览器客户端应优先门户同源形态", full.Preferred)
}
// 远程 https 反代:必须给出 wss 且省略 443
rec2 := httptest.NewRecorder()
r2 := httptest.NewRequest("GET", "/api/v1/device/gateway", nil)
r2.Host = "portal.example.com"
r2.Header.Set("X-Forwarded-Proto", "https")
r2.Header.Set("X-Forwarded-Host", "portal.example.com")
h.handleDeviceGatewayDiscovery(rec2, r2)
var got2 struct {
URL string `json:"url"`
}
json.Unmarshal(rec2.Body.Bytes(), &got2)
if got2.URL != "wss://devices.localhost/api/v1/device/ws" {
t.Errorf("https 场景 url = %q,期望 wss 且无端口", got2.URL)
}
// ★ 安全:不得把设备令牌带回来(门户凭证不该换来设备执行权)
body := rec.Body.String()
for _, leak := range []string{"ws_token", "device_gateway_token", "test-api-key", "token\":\""} {
if strings.Contains(body, leak) {
t.Errorf("发现端点泄漏了凭证相关字段 %q: %s", leak, body)
}
}
}
// 没有声明设备网关时必须明确报告不可用(客户端据此回退自配地址),
// 而不是给一个连不上的 URL。
func TestDeviceGatewayDiscoveryUnavailable(t *testing.T) {
prev := declProvider
SetProxyDeclProvider(func() []proxyDecl { return nil })
manualProxyRoutes = ""
InvalidateProxyRoutes()
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
h := NewHandler(proxyTestSettings(t))
rec := httptest.NewRecorder()
h.handleDeviceGatewayDiscovery(rec, httptest.NewRequest("GET", "/api/v1/device/gateway", nil))
var got struct {
Available bool `json:"available"`
URL string `json:"url"`
Hint string `json:"hint"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.Available {
t.Error("无声明时应报告不可用")
}
if got.URL != "" {
t.Errorf("不可用时不应给出 URL,实际 %q", got.URL)
}
if got.Hint == "" {
t.Error("不可用时应给出可操作提示")
}
}
// 发现端点必须排在门户的旧路径反代(/api/v1/device/)之前——
// 否则会被 requireAPI + 旧反代接走。
func TestDeviceGatewayDiscoveryBeatsLegacyDeviceRoute(t *testing.T) {
prev := declProvider
SetProxyDeclProvider(func() []proxyDecl {
return []proxyDecl{{
Plugin: "remotedevice", Name: "gateway", Host: "devices",
Target: "127.0.0.1:9890", WebSocket: true, Auth: sdk.ProxyAuthNone,
}}
})
manualProxyRoutes = ""
InvalidateProxyRoutes()
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
h := NewHandler(proxyTestSettings(t))
h.RegisterRoutes(http.NewServeMux())
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/api/v1/device/gateway", nil)
r.Host = "localhost:8080"
r.Header.Set("X-API-Key", "test-api-key")
h.Handler().ServeHTTP(rec, r)
if rec.Code != 200 {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
var got struct {
Available bool `json:"available"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if !got.Available {
t.Errorf("发现端点被旧 /api/v1/device/ 路由截走了: %s", rec.Body.String())
}
}
// ---- 路径挂载:非浏览器客户端(无 DNS 依赖)----
//
// *.localhost 只有浏览器内置解析特例(RFC 6761),普通进程走系统解析器
// 解析不到(实测:getent/Go 均失败)。路径挂载挂在门户自身 host 下,
// 设备客户端因此可用它已硬编码的 /api/v1/device/ws。
func TestProxyPathMount(t *testing.T) {
var gotPath string
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Write([]byte("PATH-MOUNT-OK"))
}))
defer up.Close()
prev := declProvider
SetProxyDeclProvider(func() []proxyDecl {
return []proxyDecl{{
Plugin: "remotedevice", Name: "gateway", Host: "devices",
Path: "/api/v1/device",
Target: up.Listener.Addr().String(),
Auth: sdk.ProxyAuthNone,
}}
})
manualProxyRoutes = ""
InvalidateProxyRoutes()
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
h := NewHandler(proxyTestSettings(t))
// 门户 host + 声明路径 → 必须被反代(无 DNS 依赖的那条路)
for _, p := range []string{"/api/v1/device/online", "/api/v1/device/ws", "/api/v1/device"} {
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", p, nil)
r.Host = "127.0.0.1:8080"
if !h.serveProxyHost(rec, r) {
t.Errorf("%s 应被路径挂载接住", p)
continue
}
if rec.Code != 200 || rec.Body.String() != "PATH-MOUNT-OK" {
t.Errorf("%s → code=%d body=%q", p, rec.Code, rec.Body.String())
}
}
// ★ 路径必须**原样保留**:设备客户端沿用它已硬编码的路径,
// 剥前缀会让上游 404。
if gotPath != "/api/v1/device" {
t.Errorf("上游收到的路径 = %q,期望原样 /api/v1/device(不剥前缀)", gotPath)
}
// 边界:同前缀但不同路径段**不得**被劫持
for _, p := range []string{"/api/v1/devicefoo", "/api/v1/devices/x"} {
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", p, nil)
r.Host = "127.0.0.1:8080"
if h.serveProxyHost(rec, r) {
t.Errorf("%s 不该被 /api/v1/device 前缀劫持(边界必须卡在路径分隔符)", p)
}
}
// 子域形态同时仍然可用
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/any", nil)
r.Host = "devices.localhost:8080"
if !h.serveProxyHost(rec, r) || rec.Body.String() != "PATH-MOUNT-OK" {
t.Errorf("子域形态失效: code=%d body=%q", rec.Code, rec.Body.String())
}
}
// 路径前缀冲突同样不得静默覆盖
func TestProxyPathConflictNotOverridden(t *testing.T) {
prev := declProvider
SetProxyDeclProvider(func() []proxyDecl {
return []proxyDecl{
{Plugin: "a", Name: "x", Host: "a", Path: "/api/v1/dup", Target: "127.0.0.1:1001"},
{Plugin: "b", Name: "y", Host: "b", Path: "/api/v1/dup", Target: "127.0.0.1:1002"},
}
})
manualProxyRoutes = ""
InvalidateProxyRoutes()
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
tbl := currentProxyTable()
first := tbl.paths["/api/v1/dup"]
if first == nil || first.Plugin != "a" {
t.Fatalf("先声明者应占住路径前缀: %+v", first)
}
var loser *ProxyRoute
for _, x := range tbl.ordered {
if x.Plugin == "b" {
loser = x
}
}
if loser == nil || loser.Err == "" {
t.Error("路径冲突的后者必须可见并带原因,不能静默消失")
}
}
// 发现端点必须同时给出两种形态,并标出优先项
func TestDeviceGatewayDiscoveryOffersPathForm(t *testing.T) {
prev := declProvider
SetProxyDeclProvider(func() []proxyDecl {
return []proxyDecl{{
Plugin: "remotedevice", Name: "gateway", Host: "devices",
Path: "/api/v1/device",
Target: "127.0.0.1:9890", WebSocket: true, Auth: sdk.ProxyAuthNone,
}}
})
manualProxyRoutes = ""
InvalidateProxyRoutes()
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
h := NewHandler(proxyTestSettings(t))
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/api/v1/device/gateway", nil)
r.Host = "portal.example.com"
r.Header.Set("X-Forwarded-Proto", "https")
h.handleDeviceGatewayDiscovery(rec, r)
var got struct {
URL string `json:"url"`
URLPortal string `json:"url_portal"`
Preferred string `json:"preferred"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.URL == "" {
t.Error("必须给出子域形态(浏览器用)")
}
if got.URLPortal == "" {
t.Error("必须给出门户同源形态(非浏览器用,无 DNS 依赖)")
}
if got.Preferred != "url_portal" {
t.Errorf("preferred = %q,应对非浏览器更稳的形态", got.Preferred)
}
}
// ★ 发现端点不得被路径挂载劫持。
//
// 真实实测踩到:remotedevice 声明了 Path="/api/v1/device"(auth=none,
// 凭设备令牌),于是 /api/v1/device/gateway 被它接走转给上游,上游回 401
// —— 客户端因此永远发现不到网关。
//
// 这条判据走**完整生产链**:既确认发现端点没被劫持,也确认同前缀下的
// 真实设备路径仍归反代。
func TestDiscoveryEndpointNotHijackedByPathMount(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("UPSTREAM-DEVICE"))
}))
defer up.Close()
prev := declProvider
SetProxyDeclProvider(func() []proxyDecl {
return []proxyDecl{{
Plugin: "remotedevice", Name: "gateway", Host: "devices",
Path: "/api/v1/device",
Target: up.Listener.Addr().String(),
WebSocket: true, Auth: sdk.ProxyAuthNone,
}}
})
manualProxyRoutes = ""
InvalidateProxyRoutes()
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
h := NewHandler(proxyTestSettings(t))
h.RegisterRoutes(http.NewServeMux())
// 发现端点:必须由门户处理(返回 available 字段),不得转给上游
rec := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/api/v1/device/gateway", nil)
r.Host = "127.0.0.1:18080"
r.Header.Set("X-API-Key", "test-api-key")
h.Handler().ServeHTTP(rec, r)
if rec.Code != 200 {
t.Fatalf("发现端点 code=%d body=%s", rec.Code, rec.Body.String())
}
var got struct {
Available bool `json:"available"`
URLPortal string `json:"url_portal"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("发现端点返回的不是门户 JSON(被路径挂载劫持了?): %s", rec.Body.String())
}
if !got.Available {
t.Error("应报告网关可用")
}
if !strings.Contains(got.URLPortal, "/api/v1/device/ws") {
t.Errorf("url_portal = %q,应指向声明路径", got.URLPortal)
}
// 同前缀下的真实设备路径仍必须归反代(无 auth 需求:auth=none)
rec2 := httptest.NewRecorder()
r2 := httptest.NewRequest("GET", "/api/v1/device/online", nil)
r2.Host = "127.0.0.1:18080"
h.Handler().ServeHTTP(rec2, r2)
if rec2.Body.String() != "UPSTREAM-DEVICE" {
t.Errorf("设备路径未走反代: code=%d body=%q", rec2.Code, rec2.Body.String())
}
}