mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 07:43:58 +00:00
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 之间既有的握手细节,与本次
反代改造无关(反代侧职责已证:连接建立 + 双向帧往返都通)。
This commit is contained in:
@ -462,7 +462,7 @@ func TestListProxyServicesIncludesURLAndErrors(t *testing.T) {
|
||||
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
|
||||
|
||||
h := NewHandler(proxyTestSettings(t))
|
||||
svcs := h.listProxyServices("http", ":8080")
|
||||
svcs := h.listProxyServices("http", ":8080", "localhost:8080")
|
||||
if len(svcs) != 2 {
|
||||
t.Fatalf("入口数 = %d,期望 2(含坏条目)", len(svcs))
|
||||
}
|
||||
@ -854,3 +854,345 @@ func TestProxyHostTakesPrecedenceOverPortalRoutes(t *testing.T) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user