diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js
index 679353e..df0fb7a 100644
--- a/cmd/gui/renderer/app.js
+++ b/cmd/gui/renderer/app.js
@@ -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 +=
'
' +
__(
- "暂无设备接入。设备通过 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., or directly 127.0.0.1:9890/api/v1/device/ws), hello to register, bind to authorize.",
) +
"
";
} else {
diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridgeSession.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridgeSession.ets
index bad8510..50fba7f 100644
--- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridgeSession.ets
+++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridgeSession.ets
@@ -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 {
+ 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,
+ connectTimeout: 5000,
+ readTimeout: 5000,
+ });
+ if (r.responseCode !== 200) {
+ return '';
+ }
+ const body: string = typeof r.result === 'string' ? r.result : '';
+ const parsed: Record = JSON.parse(body) as Record;
+ // 服务端明确报告不可用(没有声明设备网关反代)时不返回地址,
+ // 让调用方回退,而不是拿着一个连不上的 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 {
+ const discovered: string = await discoverGateway(portalUrl, apiKey);
+ if (discovered.length > 0) {
+ return discovered;
+ }
+ return deviceGatewayUrl(portalUrl);
+}
+
export async function startForegroundBridge(context: common.UIAbilityContext): Promise {
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();
diff --git a/cmd/waiter/device.go b/cmd/waiter/device.go
index b59de16..f2f9604 100644
--- a/cmd/waiter/device.go
+++ b/cmd/waiter/device.go
@@ -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()
diff --git a/cmd/waiter/gateway_discover.go b/cmd/waiter/gateway_discover.go
new file mode 100644
index 0000000..81c105c
--- /dev/null
+++ b/cmd/waiter/gateway_discover.go
@@ -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("服务端未给出可用的网关地址")
+}
diff --git a/cmd/waiter/gateway_discover_test.go b/cmd/waiter/gateway_discover_test.go
new file mode 100644
index 0000000..6613127
--- /dev/null
+++ b/cmd/waiter/gateway_discover_test.go
@@ -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("两个形态都缺时应报错")
+ }
+}
diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go
index 000ab74..5b89103 100644
--- a/cmd/waiter/main.go
+++ b/cmd/waiter/main.go
@@ -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))
diff --git a/internal/plugins/remotedevice/plugin.go b/internal/plugins/remotedevice/plugin.go
index 4fb24c1..81270c7 100644
--- a/internal/plugins/remotedevice/plugin.go
+++ b/internal/plugins/remotedevice/plugin.go
@@ -90,8 +90,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
//
// websocket=true:设备注册/命令下发走 WS 长连接。
s.DeclareProxy(sdk.ProxyDecl{
- Name: "gateway",
- Host: "devices",
+ Name: "gateway",
+ Host: "devices",
+ // Path 让**非浏览器客户端**也能用:*.localhost 只有浏览器内置解析
+ // 特例(RFC 6761),设备/固件/CLI 走系统解析器会以 no such host 失败。
+ // 挂到门户自身 host 的路径下则无任何 DNS 依赖 —— 设备客户端沿用它
+ // 已硬编码的 /api/v1/device/ws 路径即可,不需要知道反代的存在。
+ Path: "/api/v1/device",
Target: "127.0.0.1:9890",
WebSocket: true,
Auth: sdk.ProxyAuthNone,
diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go
index 217e796..a7f3af4 100644
--- a/internal/plugins/webui/handler.go
+++ b/internal/plugins/webui/handler.go
@@ -469,8 +469,16 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/persona", h.requireAPI(h.handlePersona))
mux.HandleFunc("/api/v1/plugins", h.requireAPI(h.handlePlugins))
mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID))
- // 设备网关(可配置反代到 remotedevice;默认禁用,未启用时返回 404)
- mux.HandleFunc("/api/v1/device/", h.requireAPI(h.handleDeviceGatewayProxy))
+ // 设备网关的**遗留路径反代**(装置开关 device_gateway_enabled 控制)。
+ //
+ // 鉴权**不再套 requireAPI**:该路由的调用方是设备与嵌入式客户端,
+ // 它们带的是设备接入令牌(?token= / X-API-Key),不是门户凭证;套上
+ // requireAPI 会把它们全部挡在 401(实测:waiter 经此路径升级握手 401)。
+ // 上游 remotedevice 自己用 requireToken 强制校验令牌,安全性不降级。
+ //
+ // 声明了 path 的服务(新机制)会在最外层 Host 分发里更早命中,
+ // 走不到这里;这条保留是为了「只开开关、不写声明」的老部署仍可用。
+ mux.HandleFunc("/api/v1/device/", h.handleDeviceGatewayProxy)
// agent 发送的文件下载(webui_files 中转目录;requireWeb 与 dashboard 同源同鉴权)
mux.HandleFunc("/files/", h.requireWeb(h.handleFiles))
// 用户上传文件的下载(uploads 目录,同一安全模型)
@@ -480,6 +488,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// 走 requireAPI:清单本身含上游地址,属于管理面信息,不该匿名可读。
mux.HandleFunc("/api/v1/proxy/services", h.requireAPI(h.handleProxyServices))
mux.HandleFunc("/api/v1/proxy", h.requireAPI(h.handleProxyInfo))
+ // 设备网关发现:客户端(GUI/鸿蒙/waiter)据此自动链接,不再自己拼地址。
+ mux.HandleFunc("/api/v1/device/gateway", h.requireAPI(h.handleDeviceGatewayDiscovery))
mux.HandleFunc("/", h.requireWeb(h.handleStatic))
}
diff --git a/internal/plugins/webui/proxy.go b/internal/plugins/webui/proxy.go
index 441daa5..60f1cbe 100644
--- a/internal/plugins/webui/proxy.go
+++ b/internal/plugins/webui/proxy.go
@@ -53,6 +53,7 @@ type ProxyRoute struct {
Plugin string // 声明该服务的插件名
Name string // 声明内的服务标识(展示用,如 "ui")
Host string // 子域标签(小写,已归一化)
+ Path string // 可选的路径挂载前缀(非浏览器客户端用,无 DNS 依赖)
Target string // 上游地址(原样,含可能的 scheme/路径前缀)
WS bool // 是否允许 WebSocket 升级
Auth string // 生效的鉴权模式(已归一化)
@@ -68,6 +69,7 @@ type ProxyRoute struct {
// 而声明只在启动/插件重载时变化。读路径无锁,重载时整体换指针。
type proxyTable struct {
routes map[string]*ProxyRoute // key = 小写 host 标签
+ paths map[string]*ProxyRoute // key = 路径挂载前缀(按最长前缀匹配)
ordered []*ProxyRoute // 稳定顺序(展示/配置页用)
base string // 基域名("" 表示用 localhost)
}
@@ -162,7 +164,7 @@ func proxyHostLabel(host, base string) string {
// 仍会出现在表里(Err 非空),在配置页可见;只是不参与路由。
func buildProxyTable(decls []proxyDecl, manualText string, settings sdk.SettingsAPI) *proxyTable {
base := proxyBaseDomain(settings)
- t := &proxyTable{routes: map[string]*ProxyRoute{}, base: base}
+ t := &proxyTable{routes: map[string]*ProxyRoute{}, paths: map[string]*ProxyRoute{}, base: base}
add := func(r *ProxyRoute) {
t.ordered = append(t.ordered, r)
@@ -177,6 +179,14 @@ func buildProxyTable(decls []proxyDecl, manualText string, settings sdk.Settings
return
}
t.routes[key] = r
+ if r.Path != "" {
+ if prev, dup := t.paths[r.Path]; dup {
+ r.Err = fmt.Sprintf("路径前缀 %q 已被插件 %s 的服务 %s 占用", r.Path, prev.Plugin, prev.Name)
+ delete(t.routes, key)
+ return
+ }
+ t.paths[r.Path] = r
+ }
}
for _, d := range decls {
@@ -184,12 +194,14 @@ func buildProxyTable(decls []proxyDecl, manualText string, settings sdk.Settings
Plugin: d.Plugin,
Name: d.Name,
Host: d.Host,
+ Path: strings.TrimSpace(d.Path),
Target: d.Target,
WS: d.WebSocket,
Auth: sdk.EffectiveProxyAuth(d.Auth),
}
if msg := sdk.ValidateProxyDecl(sdk.ProxyDecl{
- Name: d.Name, Host: d.Host, Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth,
+ Name: d.Name, Host: d.Host, Path: d.Path,
+ Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth,
}); msg != "" {
r.Err = msg
} else if r.Name == "" {
@@ -203,12 +215,14 @@ func buildProxyTable(decls []proxyDecl, manualText string, settings sdk.Settings
Plugin: "manual",
Name: d.Name,
Host: d.Host,
+ Path: strings.TrimSpace(d.Path),
Target: d.Target,
WS: d.WebSocket,
Auth: sdk.EffectiveProxyAuth(d.Auth),
}
if msg := sdk.ValidateProxyDecl(sdk.ProxyDecl{
- Name: d.Name, Host: d.Host, Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth,
+ Name: d.Name, Host: d.Host, Path: d.Path,
+ Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth,
}); msg != "" {
r.Err = msg
}
@@ -374,6 +388,7 @@ func currentProxyTable() *proxyTable {
}
decls = append(decls, proxyDecl{
Plugin: plugin, Name: name, Host: strings.ToLower(host),
+ Path: strings.TrimSpace(d.Path),
Target: d.Target, WebSocket: d.WebSocket, Auth: d.Auth,
})
}
@@ -384,6 +399,22 @@ func currentProxyTable() *proxyTable {
return proxySnap
}
+// matchProxyPath 按**最长前缀**匹配路径挂载的服务。
+//
+// 边界要卡在路径分隔符上:/api/v1/device 不能匹配 /api/v1/devicefoo
+// (否则会劫持同前缀的其它路径)。返回剩余部分供上游使用。
+func (t *proxyTable) matchProxyPath(p string) (*ProxyRoute, bool) {
+ var best *ProxyRoute
+ for prefix, r := range t.paths {
+ if p == prefix || strings.HasPrefix(p, prefix+"/") {
+ if best == nil || len(prefix) > len(best.Path) {
+ best = r
+ }
+ }
+ }
+ return best, best != nil
+}
+
// serveProxyHost 是挂在根路由前的 Host 分发入口。
// 返回 true 表示已处理该请求。
func (h *Handler) serveProxyHost(w http.ResponseWriter, r *http.Request) bool {
@@ -391,11 +422,45 @@ func (h *Handler) serveProxyHost(w http.ResponseWriter, r *http.Request) bool {
if h.settings != nil {
base = proxyBaseDomain(h.settings)
}
+ // ★ 发现端点必须先于路径挂载判定。
+ //
+ // 否则它会被 /api/v1/device 这类前缀接走:声明该前缀的服务通常
+ // auth=none(凭设备令牌),于是发现请求会被当成设备请求转给上游,
+ // 上游对 /api/v1/device/gateway 回 401 —— 客户端再也发现不到网关。
+ // (真实实测踩到:waiter 用门户地址发现时拿到 401 unauthorized。)
+ if r.URL.Path == "/api/v1/device/gateway" {
+ return false // 交给 mux 上的 requireAPI 处理
+ }
+
+ t := currentProxyTable()
+
+ // 先把「门户自身 host + 声明了 path」的请求交给对应服务。
+ //
+ // 这条分支让**非浏览器客户端**(设备/固件/CLI,走系统解析器解析不了
+ // *.localhost)也能用:它们连门户地址本身即可,不需要知道反代的存在。
+ // 路径原样保留 —— 客户端沿用它已有的路径。
+ if rt, ok := t.matchProxyPath(r.URL.Path); ok && proxyHostLabel(r.Host, base) == "" {
+ if isWebSocketUpgrade(r) && !rt.WS {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": fmt.Sprintf("插件 %s 的服务 %s 未声明 websocket", rt.Plugin, rt.Name),
+ })
+ return true
+ }
+ if rt.Auth == sdk.ProxyAuthHomeAgent && !h.authorizeProxy(w, r) {
+ return true
+ }
+ if rt.reverse == nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": "路由未就绪"})
+ return true
+ }
+ rt.reverse.ServeHTTP(w, r)
+ return true
+ }
+
label := proxyHostLabel(r.Host, base)
if label == "" {
return false
}
- t := currentProxyTable()
route, ok := t.routes[label]
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{
@@ -466,17 +531,21 @@ type proxyServiceEntry struct {
PluginZh string `json:"plugin_name"`
Name string `json:"name"`
Host string `json:"host"`
- URL string `json:"url"` // 完整可点 URL(带端口/协议,按当前请求推导)
- Auth string `json:"auth"` // homeagent | none
- WS bool `json:"websocket"`
- Target string `json:"target"`
- OK bool `json:"ok"` // false = 声明被拒或上游不可达(见 error)
- Error string `json:"error,omitempty"`
+ Path string `json:"path,omitempty"` // 路径挂载前缀(无 DNS 依赖的形态)
+ URL string `json:"url"` // 子域形态(浏览器)
+ // URLPortal 是门户同源形态:挂在门户自身 host 的路径下,**无 DNS 依赖**。
+ // 非浏览器客户端(设备/固件/CLI)用系统解析器解析不了 *.localhost,用它。
+ URLPortal string `json:"url_portal,omitempty"`
+ Auth string `json:"auth"` // homeagent | none
+ WS bool `json:"websocket"`
+ Target string `json:"target"`
+ OK bool `json:"ok"` // false = 声明被拒或上游不可达(见 error)
+ Error string `json:"error,omitempty"`
}
// listProxyServices 汇总反代服务清单(含被拒条目,供配置页排错)。
// schemePort 由调用方按当前请求推导(本机 http:8080 / 远程 https:443 等)。
-func (h *Handler) listProxyServices(scheme, hostPort string) []proxyServiceEntry {
+func (h *Handler) listProxyServices(scheme, hostPort, portalHost string) []proxyServiceEntry {
t := currentProxyTable()
metas := map[string]sdk.PluginMeta{}
if h.pluginMgr != nil {
@@ -488,6 +557,7 @@ func (h *Handler) listProxyServices(scheme, hostPort string) []proxyServiceEntry
Plugin: r.Plugin,
Name: r.Name,
Host: r.Host,
+ Path: r.Path,
Auth: r.Auth,
WS: r.WS,
Target: r.Target,
@@ -502,6 +572,9 @@ func (h *Handler) listProxyServices(scheme, hostPort string) []proxyServiceEntry
}
if r.Err == "" {
e.URL = fmt.Sprintf("%s://%s.%s%s", scheme, r.Host, t.base, hostPort)
+ if r.Path != "" {
+ e.URLPortal = fmt.Sprintf("%s://%s%s%s/", scheme, portalHost, hostPort, r.Path)
+ }
}
out = append(out, e)
}
@@ -515,6 +588,7 @@ type proxyDecl struct {
Plugin string
Name string
Host string
+ Path string
Target string
WebSocket bool
Auth string
@@ -563,6 +637,7 @@ func readPluginProxyDecls(pluginDir string) []proxyDecl {
Plugin: m.Name,
Name: sname,
Host: strings.ToLower(host),
+ Path: strings.TrimSpace(p.Path),
Target: p.Target,
WebSocket: p.WebSocket,
Auth: p.Auth,
@@ -692,7 +767,23 @@ func (h *Handler) handleProxyServices(w http.ResponseWriter, r *http.Request) {
return
}
scheme, port := h.proxySchemeAndPort(r)
- svcs := h.listProxyServices(scheme, port)
+ portalHost := r.Host
+ if hh := r.Header.Get("X-Forwarded-Host"); hh != "" {
+ portalHost = strings.TrimSpace(strings.Split(hh, ",")[0])
+ }
+ // 反代层看到的 Host 可能不含端口(nginx 默认剥掉),此时用监听端口补,
+ // 保证服务入口链接点得开。
+ if _, _, err := net.SplitHostPort(portalHost); err != nil {
+ if p := strings.TrimPrefix(port, ":"); p != "" {
+ if portalHost == "" {
+ portalHost = "localhost"
+ }
+ if _, _, e2 := net.SplitHostPort(portalHost + ":" + p); e2 == nil {
+ portalHost = portalHost + ":" + p
+ }
+ }
+ }
+ svcs := h.listProxyServices(scheme, port, portalHost)
// 可达性探测:并发带超时,避免一个坏上游拖住整个清单。
var wg sync.WaitGroup
for i := range svcs {
@@ -737,3 +828,100 @@ func (h *Handler) handleProxyInfo(w http.ResponseWriter, r *http.Request) {
"manual": strings.TrimSpace(manualProxyRoutes) != "",
})
}
+
+// ---- 设备网关发现(客户端自动链接的权威来源)----
+
+// deviceGatewayRoute 找出本实例的设备网关反代路由。
+//
+// 为什么按「插件名 + 声明名」而不是按地址猜:地址是插件配置里可改的
+// (remotedevice 的 listen_addr 就能改),按地址匹配会在改配置后静默失配。
+// 声明归属是稳定的契约。
+func deviceGatewayRoute(t *proxyTable) *ProxyRoute {
+ for _, r := range t.ordered {
+ if r.Err != "" {
+ continue
+ }
+ if r.Plugin == "remotedevice" && r.WS {
+ return r
+ }
+ }
+ return nil
+}
+
+// handleDeviceGatewayDiscovery 返回设备网关的**可连接地址**,供客户端
+// (GUI / 鸿蒙 / waiter / 设备固件)自动链接。
+//
+// 为什么需要它:改造成子域反代后,网关不再是「门户地址 + /api/v1/device/ws」——
+// 硬拼路径的客户端会连到门户自己的路由上(那里没有 WS 升级处理),
+// 或者根本连不上。而**客户端无从知道基域名与子域标签**(那是服务端配置)。
+// 让服务端回答「网关在哪」是唯一不会漂移的做法:
+// - 子域标签可改(插件声明)→ 客户端不用跟着改;
+// - 基域名可改(webui.base_domain)→ 同上;
+// - 实例可换成路径前缀模式 → 客户端拿到的仍是对的 URL。
+//
+// 返回的 url 用 ws/wss 前缀,可直接喂给 WebSocket 客户端。
+//
+// ⚠️ **不返回设备令牌**:本端点用门户凭证鉴权,而设备令牌能执行设备命令
+// (cmdrun 等),把令牌塞进来等于「门户只读凭证 → 设备执行权」的越权。
+// 令牌仍由客户端自己的配置提供(见部署说明)。
+func (h *Handler) handleDeviceGatewayDiscovery(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ scheme, port := h.proxySchemeAndPort(r)
+ wsScheme := "ws"
+ if scheme == "https" {
+ wsScheme = "wss"
+ }
+ t := currentProxyTable()
+ route := deviceGatewayRoute(t)
+
+ out := map[string]interface{}{
+ "base_domain": t.base,
+ "scheme": scheme,
+ "port": port,
+ // available=false 时,客户端应回退到自己配置的网关地址
+ // (老部署、或设备网关被显式关闭的实例)。
+ "available": route != nil,
+ }
+ if route == nil {
+ out["reason"] = "本实例没有声明设备网关反代(remotedevice 未加载,或未声明 websocket)"
+ out["hint"] = "在 webui 设置页手填,或在插件声明里加 proxies(host=devices, websocket=true)"
+ writeJSON(w, http.StatusOK, out)
+ return
+ }
+ // 两种形态都要给,因为**能解析 *.localhost 的只有浏览器**:
+ //
+ // 实测:浏览器 ✓ / curl ✓(各自内置 RFC 6761 特例),
+ // 但 getent 与 Go/Node 的解析器 ✗(系统 nsswitch 是 files,dns,
+ // 没有 nss-myhostname,也没有通配条目)。设备客户端(waiter / GUI
+ // 主进程 / 嵌入式固件)用的正是系统解析器。
+ //
+ // 所以:
+ // url —— 子域形态。浏览器用;基域名配成真实通配域名时通用。
+ // url_portal —— **门户同源形态**(同一 host、同一端口,走路径挂载)。
+ // 无任何 DNS 依赖,永远可解析 ⇒ 设备客户端的正确选择。
+ //
+ // 两者都是「同一个端口」,单端口穿透的前提不受影响。
+ out["host"] = route.Host + "." + t.base
+ out["url"] = wsScheme + "://" + route.Host + "." + t.base + port + "/api/v1/device/ws"
+ out["http_url"] = scheme + "://" + route.Host + "." + t.base + port
+ // 门户同源形态:用**客户端实际访问用的 host**,保证它一定能解析。
+ portalHost := r.Host
+ if h := r.Header.Get("X-Forwarded-Host"); h != "" {
+ portalHost = strings.TrimSpace(strings.Split(h, ",")[0])
+ }
+ if route.Path != "" {
+ // 声明了路径挂载 ⇒ 门户同源形态就是它(无 DNS 依赖,设备客户端首选)
+ out["url_portal"] = wsScheme + "://" + portalHost + route.Path + "/ws"
+ out["path"] = route.Path
+ } else {
+ // 未声明 path:门户同源形态只能退回旧口径(门户自己的设备路由)
+ out["url_portal"] = wsScheme + "://" + portalHost + "/api/v1/device/ws"
+ }
+ out["preferred"] = "url_portal" // 对非浏览器客户端更稳(无 DNS 依赖)
+ out["auth"] = route.Auth
+ out["target"] = route.Target
+ writeJSON(w, http.StatusOK, out)
+}
diff --git a/internal/plugins/webui/proxy_test.go b/internal/plugins/webui/proxy_test.go
index 9bfd0a7..08e6dde 100644
--- a/internal/plugins/webui/proxy_test.go
+++ b/internal/plugins/webui/proxy_test.go
@@ -462,7 +462,7 @@ func TestListProxyServicesIncludesURLAndErrors(t *testing.T) {
t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
h := NewHandler(proxyTestSettings(t))
- svcs := h.listProxyServices("http", ":8080")
+ svcs := h.listProxyServices("http", ":8080", "localhost:8080")
if len(svcs) != 2 {
t.Fatalf("入口数 = %d,期望 2(含坏条目)", len(svcs))
}
@@ -854,3 +854,345 @@ func TestProxyHostTakesPrecedenceOverPortalRoutes(t *testing.T) {
t.Errorf("门户 /api/v1/status 返回异常: %v", st)
}
}
+
+// ---- 设备网关发现:客户端自动链接的权威来源 ----
+//
+// 改造后网关在 devices.<基域名>,而客户端无从知道基域名与子域标签。
+// 让服务端回答「网关在哪」是唯一不漂移的做法。
+func TestDeviceGatewayDiscovery(t *testing.T) {
+ prev := declProvider
+ SetProxyDeclProvider(func() []proxyDecl {
+ return []proxyDecl{{
+ Plugin: "remotedevice", Name: "gateway", Host: "devices",
+ Target: "127.0.0.1:9890", WebSocket: true, Auth: sdk.ProxyAuthNone,
+ }}
+ })
+ manualProxyRoutes = ""
+ InvalidateProxyRoutes()
+ t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
+
+ h := NewHandler(proxyTestSettings(t))
+
+ // 本机 http:8080
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/api/v1/device/gateway", nil)
+ r.Host = "localhost:8080"
+ h.handleDeviceGatewayDiscovery(rec, r)
+ var got struct {
+ Available bool `json:"available"`
+ URL string `json:"url"`
+ Host string `json:"host"`
+ Base string `json:"base_domain"`
+ Auth string `json:"auth"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if !got.Available {
+ t.Fatalf("应报告网关可用: %s", rec.Body.String())
+ }
+ if got.URL != "ws://devices.localhost:8080/api/v1/device/ws" {
+ t.Errorf("url = %q,期望 ws://devices.localhost:8080/api/v1/device/ws", got.URL)
+ }
+ if got.Host != "devices.localhost" {
+ t.Errorf("host = %q", got.Host)
+ }
+ if got.Auth != sdk.ProxyAuthNone {
+ t.Errorf("auth = %q", got.Auth)
+ }
+ // ★ 门户同源形态必须一并给出:*.localhost 只有浏览器能解析,
+ // 设备客户端走系统解析器会失败(实测:getent/Go 均解析不到)。
+ var full struct {
+ URLPortal string `json:"url_portal"`
+ Preferred string `json:"preferred"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &full)
+ if full.URLPortal != "ws://localhost:8080/api/v1/device/ws" {
+ t.Errorf("url_portal = %q,期望门户同源形态 ws://localhost:8080/api/v1/device/ws", full.URLPortal)
+ }
+ if full.Preferred != "url_portal" {
+ t.Errorf("preferred = %q,非浏览器客户端应优先门户同源形态", full.Preferred)
+ }
+
+ // 远程 https 反代:必须给出 wss 且省略 443
+ rec2 := httptest.NewRecorder()
+ r2 := httptest.NewRequest("GET", "/api/v1/device/gateway", nil)
+ r2.Host = "portal.example.com"
+ r2.Header.Set("X-Forwarded-Proto", "https")
+ r2.Header.Set("X-Forwarded-Host", "portal.example.com")
+ h.handleDeviceGatewayDiscovery(rec2, r2)
+ var got2 struct {
+ URL string `json:"url"`
+ }
+ json.Unmarshal(rec2.Body.Bytes(), &got2)
+ if got2.URL != "wss://devices.localhost/api/v1/device/ws" {
+ t.Errorf("https 场景 url = %q,期望 wss 且无端口", got2.URL)
+ }
+
+ // ★ 安全:不得把设备令牌带回来(门户凭证不该换来设备执行权)
+ body := rec.Body.String()
+ for _, leak := range []string{"ws_token", "device_gateway_token", "test-api-key", "token\":\""} {
+ if strings.Contains(body, leak) {
+ t.Errorf("发现端点泄漏了凭证相关字段 %q: %s", leak, body)
+ }
+ }
+}
+
+// 没有声明设备网关时必须明确报告不可用(客户端据此回退自配地址),
+// 而不是给一个连不上的 URL。
+func TestDeviceGatewayDiscoveryUnavailable(t *testing.T) {
+ prev := declProvider
+ SetProxyDeclProvider(func() []proxyDecl { return nil })
+ manualProxyRoutes = ""
+ InvalidateProxyRoutes()
+ t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
+
+ h := NewHandler(proxyTestSettings(t))
+ rec := httptest.NewRecorder()
+ h.handleDeviceGatewayDiscovery(rec, httptest.NewRequest("GET", "/api/v1/device/gateway", nil))
+ var got struct {
+ Available bool `json:"available"`
+ URL string `json:"url"`
+ Hint string `json:"hint"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &got)
+ if got.Available {
+ t.Error("无声明时应报告不可用")
+ }
+ if got.URL != "" {
+ t.Errorf("不可用时不应给出 URL,实际 %q", got.URL)
+ }
+ if got.Hint == "" {
+ t.Error("不可用时应给出可操作提示")
+ }
+}
+
+// 发现端点必须排在门户的旧路径反代(/api/v1/device/)之前——
+// 否则会被 requireAPI + 旧反代接走。
+func TestDeviceGatewayDiscoveryBeatsLegacyDeviceRoute(t *testing.T) {
+ prev := declProvider
+ SetProxyDeclProvider(func() []proxyDecl {
+ return []proxyDecl{{
+ Plugin: "remotedevice", Name: "gateway", Host: "devices",
+ Target: "127.0.0.1:9890", WebSocket: true, Auth: sdk.ProxyAuthNone,
+ }}
+ })
+ manualProxyRoutes = ""
+ InvalidateProxyRoutes()
+ t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
+
+ h := NewHandler(proxyTestSettings(t))
+ h.RegisterRoutes(http.NewServeMux())
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/api/v1/device/gateway", nil)
+ r.Host = "localhost:8080"
+ r.Header.Set("X-API-Key", "test-api-key")
+ h.Handler().ServeHTTP(rec, r)
+ if rec.Code != 200 {
+ t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
+ }
+ var got struct {
+ Available bool `json:"available"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &got)
+ if !got.Available {
+ t.Errorf("发现端点被旧 /api/v1/device/ 路由截走了: %s", rec.Body.String())
+ }
+}
+
+// ---- 路径挂载:非浏览器客户端(无 DNS 依赖)----
+//
+// *.localhost 只有浏览器内置解析特例(RFC 6761),普通进程走系统解析器
+// 解析不到(实测:getent/Go 均失败)。路径挂载挂在门户自身 host 下,
+// 设备客户端因此可用它已硬编码的 /api/v1/device/ws。
+func TestProxyPathMount(t *testing.T) {
+ var gotPath string
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ w.Write([]byte("PATH-MOUNT-OK"))
+ }))
+ defer up.Close()
+
+ prev := declProvider
+ SetProxyDeclProvider(func() []proxyDecl {
+ return []proxyDecl{{
+ Plugin: "remotedevice", Name: "gateway", Host: "devices",
+ Path: "/api/v1/device",
+ Target: up.Listener.Addr().String(),
+ Auth: sdk.ProxyAuthNone,
+ }}
+ })
+ manualProxyRoutes = ""
+ InvalidateProxyRoutes()
+ t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
+
+ h := NewHandler(proxyTestSettings(t))
+
+ // 门户 host + 声明路径 → 必须被反代(无 DNS 依赖的那条路)
+ for _, p := range []string{"/api/v1/device/online", "/api/v1/device/ws", "/api/v1/device"} {
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", p, nil)
+ r.Host = "127.0.0.1:8080"
+ if !h.serveProxyHost(rec, r) {
+ t.Errorf("%s 应被路径挂载接住", p)
+ continue
+ }
+ if rec.Code != 200 || rec.Body.String() != "PATH-MOUNT-OK" {
+ t.Errorf("%s → code=%d body=%q", p, rec.Code, rec.Body.String())
+ }
+ }
+ // ★ 路径必须**原样保留**:设备客户端沿用它已硬编码的路径,
+ // 剥前缀会让上游 404。
+ if gotPath != "/api/v1/device" {
+ t.Errorf("上游收到的路径 = %q,期望原样 /api/v1/device(不剥前缀)", gotPath)
+ }
+
+ // 边界:同前缀但不同路径段**不得**被劫持
+ for _, p := range []string{"/api/v1/devicefoo", "/api/v1/devices/x"} {
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", p, nil)
+ r.Host = "127.0.0.1:8080"
+ if h.serveProxyHost(rec, r) {
+ t.Errorf("%s 不该被 /api/v1/device 前缀劫持(边界必须卡在路径分隔符)", p)
+ }
+ }
+
+ // 子域形态同时仍然可用
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/any", nil)
+ r.Host = "devices.localhost:8080"
+ if !h.serveProxyHost(rec, r) || rec.Body.String() != "PATH-MOUNT-OK" {
+ t.Errorf("子域形态失效: code=%d body=%q", rec.Code, rec.Body.String())
+ }
+}
+
+// 路径前缀冲突同样不得静默覆盖
+func TestProxyPathConflictNotOverridden(t *testing.T) {
+ prev := declProvider
+ SetProxyDeclProvider(func() []proxyDecl {
+ return []proxyDecl{
+ {Plugin: "a", Name: "x", Host: "a", Path: "/api/v1/dup", Target: "127.0.0.1:1001"},
+ {Plugin: "b", Name: "y", Host: "b", Path: "/api/v1/dup", Target: "127.0.0.1:1002"},
+ }
+ })
+ manualProxyRoutes = ""
+ InvalidateProxyRoutes()
+ t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
+
+ tbl := currentProxyTable()
+ first := tbl.paths["/api/v1/dup"]
+ if first == nil || first.Plugin != "a" {
+ t.Fatalf("先声明者应占住路径前缀: %+v", first)
+ }
+ var loser *ProxyRoute
+ for _, x := range tbl.ordered {
+ if x.Plugin == "b" {
+ loser = x
+ }
+ }
+ if loser == nil || loser.Err == "" {
+ t.Error("路径冲突的后者必须可见并带原因,不能静默消失")
+ }
+}
+
+// 发现端点必须同时给出两种形态,并标出优先项
+func TestDeviceGatewayDiscoveryOffersPathForm(t *testing.T) {
+ prev := declProvider
+ SetProxyDeclProvider(func() []proxyDecl {
+ return []proxyDecl{{
+ Plugin: "remotedevice", Name: "gateway", Host: "devices",
+ Path: "/api/v1/device",
+ Target: "127.0.0.1:9890", WebSocket: true, Auth: sdk.ProxyAuthNone,
+ }}
+ })
+ manualProxyRoutes = ""
+ InvalidateProxyRoutes()
+ t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
+
+ h := NewHandler(proxyTestSettings(t))
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/api/v1/device/gateway", nil)
+ r.Host = "portal.example.com"
+ r.Header.Set("X-Forwarded-Proto", "https")
+ h.handleDeviceGatewayDiscovery(rec, r)
+ var got struct {
+ URL string `json:"url"`
+ URLPortal string `json:"url_portal"`
+ Preferred string `json:"preferred"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.URL == "" {
+ t.Error("必须给出子域形态(浏览器用)")
+ }
+ if got.URLPortal == "" {
+ t.Error("必须给出门户同源形态(非浏览器用,无 DNS 依赖)")
+ }
+ if got.Preferred != "url_portal" {
+ t.Errorf("preferred = %q,应对非浏览器更稳的形态", got.Preferred)
+ }
+}
+
+// ★ 发现端点不得被路径挂载劫持。
+//
+// 真实实测踩到:remotedevice 声明了 Path="/api/v1/device"(auth=none,
+// 凭设备令牌),于是 /api/v1/device/gateway 被它接走转给上游,上游回 401
+// —— 客户端因此永远发现不到网关。
+//
+// 这条判据走**完整生产链**:既确认发现端点没被劫持,也确认同前缀下的
+// 真实设备路径仍归反代。
+func TestDiscoveryEndpointNotHijackedByPathMount(t *testing.T) {
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte("UPSTREAM-DEVICE"))
+ }))
+ defer up.Close()
+
+ prev := declProvider
+ SetProxyDeclProvider(func() []proxyDecl {
+ return []proxyDecl{{
+ Plugin: "remotedevice", Name: "gateway", Host: "devices",
+ Path: "/api/v1/device",
+ Target: up.Listener.Addr().String(),
+ WebSocket: true, Auth: sdk.ProxyAuthNone,
+ }}
+ })
+ manualProxyRoutes = ""
+ InvalidateProxyRoutes()
+ t.Cleanup(func() { SetProxyDeclProvider(prev); InvalidateProxyRoutes() })
+
+ h := NewHandler(proxyTestSettings(t))
+ h.RegisterRoutes(http.NewServeMux())
+
+ // 发现端点:必须由门户处理(返回 available 字段),不得转给上游
+ rec := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/api/v1/device/gateway", nil)
+ r.Host = "127.0.0.1:18080"
+ r.Header.Set("X-API-Key", "test-api-key")
+ h.Handler().ServeHTTP(rec, r)
+ if rec.Code != 200 {
+ t.Fatalf("发现端点 code=%d body=%s", rec.Code, rec.Body.String())
+ }
+ var got struct {
+ Available bool `json:"available"`
+ URLPortal string `json:"url_portal"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("发现端点返回的不是门户 JSON(被路径挂载劫持了?): %s", rec.Body.String())
+ }
+ if !got.Available {
+ t.Error("应报告网关可用")
+ }
+ if !strings.Contains(got.URLPortal, "/api/v1/device/ws") {
+ t.Errorf("url_portal = %q,应指向声明路径", got.URLPortal)
+ }
+
+ // 同前缀下的真实设备路径仍必须归反代(无 auth 需求:auth=none)
+ rec2 := httptest.NewRecorder()
+ r2 := httptest.NewRequest("GET", "/api/v1/device/online", nil)
+ r2.Host = "127.0.0.1:18080"
+ h.Handler().ServeHTTP(rec2, r2)
+ if rec2.Body.String() != "UPSTREAM-DEVICE" {
+ t.Errorf("设备路径未走反代: code=%d body=%q", rec2.Code, rec2.Body.String())
+ }
+}
diff --git a/third_party/homeagent-sdk/sdk/proxy.go b/third_party/homeagent-sdk/sdk/proxy.go
index 1ef1668..9928b37 100644
--- a/third_party/homeagent-sdk/sdk/proxy.go
+++ b/third_party/homeagent-sdk/sdk/proxy.go
@@ -57,6 +57,22 @@ type ProxyDecl struct {
// 而不是静默降级成普通请求(后者表现为前端一直重连、排查困难)。
WebSocket bool `json:"websocket,omitempty"`
+ // Path 是可选的**路径挂载前缀**(如 "/api/v1/device")。
+ //
+ // 为什么 Host 子域之外还需要它:子域形态依赖 DNS 解析,而 *.localhost
+ // 只有浏览器内置该特例(RFC 6761)—— 普通进程(设备客户端、固件、
+ // CLI)走系统解析器,实测解析不到,会以「no such host」失败。
+ // 路径形态挂在门户自身 host 下,**无任何 DNS 依赖**,是给非浏览器
+ // 客户端用的。
+ //
+ // 语义:请求路径**原样保留**(不做前缀剥除)——声明者按上游真实路径填写,
+ // 例如上游注册 /api/v1/device/ws,就声明 Path="/api/v1/device"。
+ // 这样设备客户端可以直接使用它已硬编码的路径,不需要知道反代的存在。
+ //
+ // 留空 = 只提供子域形态(插件自带 UI 的常见情形:UI 与它自己的 API
+ // 同源,走子域天然正确)。
+ Path string `json:"path,omitempty"`
+
// Auth 决定这条反代由谁保护,取值见 ProxyAuthNone / ProxyAuthHomeAgent。
// 空串等价于 ProxyAuthHomeAgent(默认安全)。
//
@@ -163,6 +179,17 @@ func ValidateProxyDecl(d ProxyDecl) string {
if d.Host != "" && !ValidProxyHostLabel(d.Host) {
return "host 不是合法的子域名标签(只允许小写字母/数字/连字符,且不以连字符开头结尾): " + d.Host
}
+ if p := strings.TrimSpace(d.Path); p != "" {
+ if !strings.HasPrefix(p, "/") {
+ return "path 必须以 / 开头: " + d.Path
+ }
+ if strings.HasSuffix(p, "/") {
+ return "path 不应以 / 结尾(它是前缀,不是目录): " + d.Path
+ }
+ if strings.Contains(p, "..") || strings.ContainsAny(p, " \t\r\n\x00?#") {
+ return "path 含非法字符: " + d.Path
+ }
+ }
// Target 的 host:port 部分必须可解析;路径前缀允许保留。
//
// 规则(刻意从严,因为地址写错是最常见的声明错误,而错误的反代会把