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

135 lines
4.8 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"
"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("服务端未给出可用的网关地址")
}