mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 13:23:03 +00:00
配套 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 之间既有的握手细节,与本次
反代改造无关(反代侧职责已证:连接建立 + 双向帧往返都通)。
152 lines
5.3 KiB
Go
152 lines
5.3 KiB
Go
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("两个形态都缺时应报错")
|
||
}
|
||
}
|