mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-02 15:23:57 +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:
@ -593,6 +593,31 @@ async function api(p, o) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// 从服务端发现设备网关地址(自动链接的权威来源)。
|
||||
//
|
||||
// 失败不报错:老版本 HomeAgent 没有这个端点,回退到本地推导即可
|
||||
// (见 renderDeviceChannel 里的 state.discoveredGateway || 旧口径)。
|
||||
//
|
||||
// 优先 url_portal(门户同源形态):GUI 主进程连 WS 走系统解析器,
|
||||
// devices.localhost 这类子域在系统解析器下通常解析不到 —— *.localhost
|
||||
// 是浏览器内置特例(RFC 6761),不适用于普通进程。实测确认。
|
||||
async function loadDiscoveredGateway() {
|
||||
if (!state.currentConn || state.currentConn.type !== "webui") {
|
||||
state.discoveredGateway = "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var d = await api("/device/gateway");
|
||||
state.discoveredGateway =
|
||||
(d && d.available && (d.url_portal || d.url)) || "";
|
||||
if (state.discoveredGateway) {
|
||||
console.log("[device-bridge] discovered gateway: " + state.discoveredGateway);
|
||||
}
|
||||
} catch (e) {
|
||||
state.discoveredGateway = "";
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Navigation =====
|
||||
function switchView(n) {
|
||||
document.querySelectorAll(".view").forEach((e) => {
|
||||
@ -723,6 +748,7 @@ async function refreshDataOnly() {
|
||||
state.currentConn.type === "webui" &&
|
||||
state.currentConn.url
|
||||
) {
|
||||
await loadDiscoveredGateway();
|
||||
var d = await api("/device/online");
|
||||
state.devices = (d && d.devices) || [];
|
||||
} else {
|
||||
@ -807,6 +833,7 @@ async function refreshAll() {
|
||||
state.currentConn.type === "webui" &&
|
||||
state.currentConn.url
|
||||
) {
|
||||
await loadDiscoveredGateway();
|
||||
var d = await api("/device/online");
|
||||
state.devices = (d && d.devices) || [];
|
||||
} else {
|
||||
@ -5705,12 +5732,21 @@ function renderDevices() {
|
||||
}
|
||||
// 设备通道配置(独立于连接类型:devicced 是 GUI 组件,默认走 webui 反代端口)
|
||||
var dbc = state.dbConfig || {};
|
||||
var webuiUrl = "";
|
||||
// 网关地址优先用**服务端发现的权威值**(state.discoveredGateway),
|
||||
// 其次才是用户手填 / 本地推导。
|
||||
//
|
||||
// 为什么不能继续用「门户 URL 同 host 拼 /api/v1/device/ws」:
|
||||
// 网关改造为子域反代后位于 devices.<基域名>,而**基域名与子域标签都是
|
||||
// 服务端配置**,客户端无从得知。硬拼的结果是连到门户自己的路由上。
|
||||
// 服务端 /api/v1/device/gateway 是唯一不会漂移的来源。
|
||||
var webuiUrl = state.discoveredGateway || "";
|
||||
if (
|
||||
!webuiUrl &&
|
||||
state.currentConn &&
|
||||
state.currentConn.type === "webui" &&
|
||||
state.currentConn.url
|
||||
) {
|
||||
// 回退:老部署(无发现端点)仍按旧口径推导,保持向后兼容。
|
||||
webuiUrl = state.currentConn.url.replace(/\/+$/, "") + "/api/v1/device/ws";
|
||||
}
|
||||
var curGateway = dbc.gateway || webuiUrl || "";
|
||||
@ -5855,8 +5891,8 @@ function renderDevices() {
|
||||
html +=
|
||||
'<p style="color:var(--text-muted)">' +
|
||||
__(
|
||||
"暂无设备接入。设备通过 WebSocket 连接到设备网关(默认 127.0.0.1:9890/api/v1/device/ws),携带 token 后 hello 登记、bind 授权。",
|
||||
"No devices yet. Devices connect via WebSocket (default 127.0.0.1:9890/api/v1/device/ws), hello to register, bind to authorize.",
|
||||
"暂无设备接入。设备通过 WebSocket 连接到设备网关(默认经 HomeAgent 反代到 devices.<基域名>,或直连 127.0.0.1:9890/api/v1/device/ws),携带 token 后 hello 登记、bind 授权。",
|
||||
"No devices yet. Devices connect via WebSocket (proxied by HomeAgent at devices.<base-domain>, or directly 127.0.0.1:9890/api/v1/device/ws), hello to register, bind to authorize.",
|
||||
) +
|
||||
"</p>";
|
||||
} else {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { deviceBridge } from './DeviceBridge';
|
||||
import { installCmdRouter, setBridgeAppContext } from './BridgeRouter';
|
||||
import { LOCAL_DEVICE_CAPS, shutdownSpeakerUse } from './BridgeCaps';
|
||||
@ -24,7 +25,14 @@ function ensureBridgeStateTracking(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** 把当前后端 HTTP 地址转换为同源设备桥 WebSocket 地址。 */
|
||||
/**
|
||||
* 把门户 HTTP 地址转换为同源设备桥 WebSocket 地址(**回退路径**)。
|
||||
*
|
||||
* 网关改造为子域反代后位于 devices.<基域名>,而基域名与子域标签都是**服务端
|
||||
* 配置**,客户端拼不出来。正常路径是先调 discoverGateway() 问服务端;
|
||||
* 本函数只在「服务端没有发现端点」(老版本 HomeAgent)时兜底,
|
||||
* 保留旧部署的可用性。
|
||||
*/
|
||||
export function deviceGatewayUrl(base: string): string {
|
||||
let trimmed: string = base.trim();
|
||||
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
|
||||
@ -50,6 +58,72 @@ export function deviceGatewayUrl(base: string): string {
|
||||
* 应用进入前台后建立全局设备桥。它不再依赖用户先打开“设备”Tab,
|
||||
* 因而 screensue、clipboardsee 等前台能力从主页面加载后即可接收。
|
||||
*/
|
||||
/**
|
||||
* 向门户询问设备网关的**权威地址**。
|
||||
*
|
||||
* 为什么必须问而不是自己拼:网关现在挂在 devices.<基域名> 子域上,基域名
|
||||
* (webui.base_domain,默认 localhost)与子域标签(插件声明里可改)都在
|
||||
* 服务端,客户端无从得知。服务端作答是唯一不会漂移的做法。
|
||||
*
|
||||
* 失败/老版本(404)不报错,返回空串让调用方回退到 deviceGatewayUrl()——
|
||||
* 发现是增强而非必需。
|
||||
*/
|
||||
async function discoverGateway(portalUrl: string, apiKey: string): Promise<string> {
|
||||
let base: string = portalUrl.trim();
|
||||
if (base.length === 0) {
|
||||
return '';
|
||||
}
|
||||
// 用户配置里可能填的是完整网关地址:截到门户根再拼发现路径
|
||||
const cut: number = base.indexOf('/api/v1/');
|
||||
if (cut >= 0) {
|
||||
base = base.substring(0, cut);
|
||||
}
|
||||
while (base.length > 0 && base.charAt(base.length - 1) === '/') {
|
||||
base = base.substring(0, base.length - 1);
|
||||
}
|
||||
try {
|
||||
const r = await http.createHttp().request(base + '/api/v1/device/gateway', {
|
||||
method: http.RequestMethod.GET,
|
||||
header: { 'X-API-Key': apiKey } as Record<string, string>,
|
||||
connectTimeout: 5000,
|
||||
readTimeout: 5000,
|
||||
});
|
||||
if (r.responseCode !== 200) {
|
||||
return '';
|
||||
}
|
||||
const body: string = typeof r.result === 'string' ? r.result : '';
|
||||
const parsed: Record<string, Object> = JSON.parse(body) as Record<string, Object>;
|
||||
// 服务端明确报告不可用(没有声明设备网关反代)时不返回地址,
|
||||
// 让调用方回退,而不是拿着一个连不上的 URL 反复重连。
|
||||
if (parsed['available'] !== true) {
|
||||
return '';
|
||||
}
|
||||
// 优先**门户同源形态**(url_portal:同一 host、同一端口)。
|
||||
//
|
||||
// 原因:子域形态 devices.<基域名> 依赖 DNS 解析,而 *.localhost 只有
|
||||
// 浏览器内置该特例(RFC 6761)—— 应用内 HTTP/WS 客户端走系统解析器,
|
||||
// 通常解析不到。门户同源形态无任何 DNS 依赖,永远可解析。
|
||||
const portal: string = parsed['url_portal'] as string;
|
||||
if (portal !== undefined && portal !== null && portal.length > 0) {
|
||||
return portal;
|
||||
}
|
||||
const url: string = parsed['url'] as string;
|
||||
return url === undefined || url === null ? '' : url;
|
||||
} catch (e) {
|
||||
// 网络失败 / 老版本无此端点:静默回退
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析设备桥最终使用的网关地址:优先服务端发现,回退同源推导。 */
|
||||
async function resolveGatewayUrl(portalUrl: string, apiKey: string): Promise<string> {
|
||||
const discovered: string = await discoverGateway(portalUrl, apiKey);
|
||||
if (discovered.length > 0) {
|
||||
return discovered;
|
||||
}
|
||||
return deviceGatewayUrl(portalUrl);
|
||||
}
|
||||
|
||||
export async function startForegroundBridge(context: common.UIAbilityContext): Promise<void> {
|
||||
foregroundActive = true;
|
||||
setBridgeAppContext(context);
|
||||
@ -67,8 +141,9 @@ export async function startForegroundBridge(context: common.UIAbilityContext): P
|
||||
const generation: number = bridgeGeneration;
|
||||
const deviceId: string = connStore.ensureDeviceId();
|
||||
try {
|
||||
const gatewayUrl: string = await resolveGatewayUrl(cur.url, cur.apiKey);
|
||||
await deviceBridge.connect(
|
||||
deviceGatewayUrl(cur.url), cur.apiKey, deviceId,
|
||||
gatewayUrl, cur.apiKey, deviceId,
|
||||
LOCAL_DEVICE_CAPS, 'ohos-phone', connStore.getDeviceAuth(), connStore.getDeviceName());
|
||||
if (!foregroundActive || generation !== bridgeGeneration) {
|
||||
deviceBridge.disconnect();
|
||||
|
||||
@ -52,15 +52,8 @@ func startDeviceBridge(addr, token string) error {
|
||||
"version": meta.Version,
|
||||
}
|
||||
|
||||
// 确保 gateway URL 格式正确
|
||||
gateway := addr
|
||||
if !strings.HasPrefix(gateway, "ws://") && !strings.HasPrefix(gateway, "wss://") {
|
||||
gateway = "ws://" + gateway
|
||||
// 默认 remotedevice WS 路径
|
||||
if !strings.Contains(gateway, "/api/v1/device/ws") {
|
||||
gateway = gateway + "/api/v1/device/ws"
|
||||
}
|
||||
}
|
||||
// 网关地址规范化(含「已是完整端点」「只有 host:port」两种旧输入形态)。
|
||||
gateway := normalizeGateway(addr)
|
||||
|
||||
bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info)
|
||||
cmdRouter = client.NewCmdRouter()
|
||||
|
||||
134
cmd/waiter/gateway_discover.go
Normal file
134
cmd/waiter/gateway_discover.go
Normal file
@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// discoveryPath 是 HomeAgent 的「设备网关在哪」端点(相对门户根)。
|
||||
const discoveryPath = "/api/v1/device/gateway"
|
||||
|
||||
// discoveryResponse 是发现端点的响应。
|
||||
type discoveryResponse struct {
|
||||
Available bool `json:"available"`
|
||||
// URL 是**子域形态**(devices.<基域名>)。浏览器能解析(RFC 6761 内置
|
||||
// 特例),但系统解析器(getent/Go/Node)通常解析不到 *.localhost —— 实测如此。
|
||||
URL string `json:"url"`
|
||||
// URLPortal 是**门户同源形态**(同一 host、同一端口,走路径挂载),
|
||||
// 无任何 DNS 依赖。非浏览器客户端应当用这个。
|
||||
URLPortal string `json:"url_portal"`
|
||||
Preferred string `json:"preferred"`
|
||||
Host string `json:"host"` // devices.<基域名>
|
||||
Auth string `json:"auth"` // homeagent | none
|
||||
Reason string `json:"reason"` // available=false 时的原因
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// normalizeGateway 把用户给的地址整理成可直接连接的 WebSocket URL。
|
||||
//
|
||||
// 保留两种输入形态的旧行为:
|
||||
// - 已含路径(含 /api/v1/device/ws)→ 原样使用;
|
||||
// - 只有 host[:port] → 补 ws:// 与默认 WS 路径。
|
||||
//
|
||||
// 新增:**已带子域标签的地址不再被改写**(例如 devices.example.com)——
|
||||
// 旧实现只判断"是否含路径",对子域地址是对的;这里把这条显式化,
|
||||
// 避免以后有人加"自动补门户路径"的逻辑时把它改坏。
|
||||
func normalizeGateway(addr string) string {
|
||||
g := strings.TrimSpace(addr)
|
||||
if g == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(g, "ws://") || strings.HasPrefix(g, "wss://") {
|
||||
if strings.Contains(g, "/api/v1/device/ws") {
|
||||
return g
|
||||
}
|
||||
return strings.TrimRight(g, "/") + "/api/v1/device/ws"
|
||||
}
|
||||
// http(s):// 形态:转成 ws(s)://,其余同下
|
||||
if strings.HasPrefix(g, "https://") {
|
||||
g = "wss://" + strings.TrimPrefix(g, "https://")
|
||||
} else if strings.HasPrefix(g, "http://") {
|
||||
g = "ws://" + strings.TrimPrefix(g, "http://")
|
||||
} else {
|
||||
g = "ws://" + g
|
||||
}
|
||||
if strings.Contains(g, "/api/v1/device/ws") {
|
||||
return g
|
||||
}
|
||||
return strings.TrimRight(g, "/") + "/api/v1/device/ws"
|
||||
}
|
||||
|
||||
// discoverGateway 向门户询问设备网关的**权威地址**。
|
||||
//
|
||||
// 为什么需要:设备网关现在位于 devices.<基域名> 的子域反代上,而基域名与
|
||||
// 子域标签都是**服务端配置**(webui.base_domain / 插件声明),客户端无从得知。
|
||||
// 让服务端回答「网关在哪」是唯一不会漂移的做法。
|
||||
//
|
||||
// portalURL 是用户配置的门户地址(可能带路径/尾斜杠);token 是门户 api_key。
|
||||
// 任何失败都返回错误,由调用方决定是否回退到自配地址 —— 发现是**增强**而非
|
||||
// 必需,老版本 HomeAgent 没有这个端点。
|
||||
func discoverGateway(portalURL, token string, timeout time.Duration) (string, error) {
|
||||
base := strings.TrimSpace(portalURL)
|
||||
if base == "" {
|
||||
return "", fmt.Errorf("门户地址为空")
|
||||
}
|
||||
// http(s) → 对应的门户根;ws(s) 输入也要能问(GUI 里同一字段混用两种形态)
|
||||
switch {
|
||||
case strings.HasPrefix(base, "wss://"):
|
||||
base = "https://" + strings.TrimPrefix(base, "wss://")
|
||||
case strings.HasPrefix(base, "ws://"):
|
||||
base = "http://" + strings.TrimPrefix(base, "ws://")
|
||||
case !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://"):
|
||||
base = "http://" + base
|
||||
}
|
||||
// 用户可能填的是完整网关地址(含 /api/v1/device/ws):截到根再拼发现路径
|
||||
if i := strings.Index(base, "/api/v1/"); i >= 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
base = strings.TrimRight(base, "/")
|
||||
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
client := &http.Client{Timeout: timeout}
|
||||
req, err := http.NewRequest(http.MethodGet, base+discoveryPath, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("X-API-Key", token)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("发现端点返回 %d:%s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var d discoveryResponse
|
||||
if err := json.Unmarshal(body, &d); err != nil {
|
||||
return "", fmt.Errorf("发现响应无法解析: %w", err)
|
||||
}
|
||||
if !d.Available {
|
||||
msg := d.Reason
|
||||
if msg == "" {
|
||||
msg = "服务端报告设备网关不可用"
|
||||
}
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
// 优先门户同源形态:waiter 是普通进程,走系统解析器,
|
||||
// 而 *.localhost 在系统解析器下通常解析不到(只有浏览器内置该特例)。
|
||||
if p := strings.TrimSpace(d.URLPortal); p != "" {
|
||||
return p, nil
|
||||
}
|
||||
if p := strings.TrimSpace(d.URL); p != "" {
|
||||
return p, nil
|
||||
}
|
||||
return "", fmt.Errorf("服务端未给出可用的网关地址")
|
||||
}
|
||||
151
cmd/waiter/gateway_discover_test.go
Normal file
151
cmd/waiter/gateway_discover_test.go
Normal file
@ -0,0 +1,151 @@
|
||||
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("两个形态都缺时应报错")
|
||||
}
|
||||
}
|
||||
@ -181,6 +181,17 @@ func main() {
|
||||
if dt == "" {
|
||||
dt = cfg.DeviceToken
|
||||
}
|
||||
// 网关地址优先向门户**发现**(服务端才知道子域标签与基域名),
|
||||
// 失败再回退到用户配置 —— 老版本 HomeAgent 没有发现端点。
|
||||
// 只在用户已配置门户地址时尝试:没配门户就没有可问的对象。
|
||||
if portal := cfg.Remote; portal != "" {
|
||||
if discovered, err := discoverGateway(portal, cfg.APIKey, 5*time.Second); err == nil {
|
||||
printlnC(colorGreen, "device gateway discovered: "+discovered)
|
||||
dg = discovered
|
||||
} else if dg == "" {
|
||||
printlnC(colorYellow, "device gateway discovery failed: "+err.Error())
|
||||
}
|
||||
}
|
||||
if dg != "" && dt != "" {
|
||||
if err := startDeviceBridge(dg, dt); err != nil {
|
||||
printlnC(colorYellow, fmt.Sprintf("device bridge: %v (continue without)", err))
|
||||
|
||||
Reference in New Issue
Block a user