Files
HomeAgent/cmd/waiter/gateway_discover_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

152 lines
5.3 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 main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNormalizeGateway(t *testing.T) {
cases := map[string]string{
// 已是完整端点:原样
"ws://devices.localhost:8080/api/v1/device/ws": "ws://devices.localhost:8080/api/v1/device/ws",
"wss://devices.example.com/api/v1/device/ws": "wss://devices.example.com/api/v1/device/ws",
// 只有 ws 根:补路径
"ws://127.0.0.1:9890": "ws://127.0.0.1:9890/api/v1/device/ws",
"ws://devices.example.com/": "ws://devices.example.com/api/v1/device/ws",
// 裸 host:port:补 scheme + 路径(旧行为)
"127.0.0.1:9890": "ws://127.0.0.1:9890/api/v1/device/ws",
"devices.example.com:8080": "ws://devices.example.com:8080/api/v1/device/ws",
// http(s) → ws(s)
"http://127.0.0.1:9890": "ws://127.0.0.1:9890/api/v1/device/ws",
"https://devices.example.com": "wss://devices.example.com/api/v1/device/ws",
// ★ 子域地址不得被改写(这正是改造后的正确形态)
"devices.example.com": "ws://devices.example.com/api/v1/device/ws",
// 空
"": "",
" ": "",
}
for in, want := range cases {
if got := normalizeGateway(in); got != want {
t.Errorf("normalizeGateway(%q) = %q,期望 %q", in, got, want)
}
}
}
// 发现端点返回权威地址时,必须采用它(而不是自己拼门户同源地址)。
func TestDiscoverGatewayUsesServerAnswer(t *testing.T) {
var gotPath, gotKey string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotKey = r.Header.Get("X-API-Key")
json.NewEncoder(w).Encode(map[string]interface{}{
"available": true,
"url": "ws://devices.localhost:8080/api/v1/device/ws",
"url_portal": "ws://127.0.0.1:8080/api/v1/device/ws",
"auth": "none",
})
}))
defer srv.Close()
url, err := discoverGateway(srv.URL, "PORTAL-KEY", 3*time.Second)
if err != nil {
t.Fatal(err)
}
// ★ 必须优先门户同源形态:waiter 走系统解析器,*.localhost 解析不到
if url != "ws://127.0.0.1:8080/api/v1/device/ws" {
t.Errorf("未优先采用门户同源形态: %q", url)
}
if gotPath != "/api/v1/device/gateway" {
t.Errorf("发现路径不对: %q", gotPath)
}
if gotKey != "PORTAL-KEY" {
t.Errorf("未带门户凭证: %q", gotKey)
}
}
// 用户填的是完整网关地址时,也要能正确截到门户根再问。
func TestDiscoverGatewayFromFullEndpointInput(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
json.NewEncoder(w).Encode(map[string]interface{}{
"available": true, "url": "ws://devices.localhost/api/v1/device/ws",
})
}))
defer srv.Close()
// 输入形态:完整旧端点(含 /api/v1/device/ws)与 ws:// 前缀
for _, in := range []string{
srv.URL + "/api/v1/device/ws",
"ws://" + srv.Listener.Addr().String() + "/api/v1/device/ws",
} {
gotPath = ""
if _, err := discoverGateway(in, "k", 3*time.Second); err != nil {
t.Errorf("输入 %q 应成功: %v", in, err)
continue
}
if gotPath != "/api/v1/device/gateway" {
t.Errorf("输入 %q 未截到门户根,实际路径 %q", in, gotPath)
}
}
}
// 服务端明确报告不可用 → 必须返回错误(调用方据此回退),而不是给个连不上的 URL。
func TestDiscoverGatewayUnavailableReportsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"available": false,
"reason": "本实例没有声明设备网关反代",
})
}))
defer srv.Close()
if _, err := discoverGateway(srv.URL, "k", 3*time.Second); err == nil {
t.Error("服务端报告不可用时应返回错误")
}
}
// 老版本 HomeAgent 没有该端点(404)→ 返回错误而不是 panic/空成功。
func TestDiscoverGatewayOldServerFallsBack(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
defer srv.Close()
if _, err := discoverGateway(srv.URL, "k", 3*time.Second); err == nil {
t.Error("404 应返回错误,让调用方回退到自配地址")
}
// 空地址快速失败
if _, err := discoverGateway("", "k", 3*time.Second); err == nil {
t.Error("空门户地址应返回错误")
}
}
// 老版本只给 url(无 url_portal)时,仍必须能用 —— 退回子域形态。
func TestDiscoverGatewayFallsBackToSubdomainForm(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"available": true,
"url": "ws://devices.example.com/api/v1/device/ws",
})
}))
defer srv.Close()
got, err := discoverGateway(srv.URL, "k", 3*time.Second)
if err != nil {
t.Fatal(err)
}
if got != "ws://devices.example.com/api/v1/device/ws" {
t.Errorf("无 url_portal 时应退回 url,实际 %q", got)
}
}
// 两者都没有 → 明确报错,而不是返回空串让调用方拿着空地址去连。
func TestDiscoverGatewayNoURLReportsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{"available": true})
}))
defer srv.Close()
if _, err := discoverGateway(srv.URL, "k", 3*time.Second); err == nil {
t.Error("两个形态都缺时应报错")
}
}