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:
JianFeeeee
2026-09-25 14:30:41 +08:00
parent 5d278cffdb
commit 7f5bf1670f
11 changed files with 1003 additions and 31 deletions

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