fix(gui): 设备接口补设备令牌,设备页不再恒 401

## 现象
GUI 设备页长期空白。日志每 15s 一条:
    GET 401 /api/v1/device/online

## 根因:/api/v1/device/ 命名空间下并存两套鉴权
  · /device/gateway    注册在 webui 插件,走 requireAPI,认 session cookie
  · /device/online、/device/、/device/push
                       注册在 remotedevice 插件,走 requireToken,
                       **只认 X-API-Key 头或 ?token=,完全不认 cookie**
实测可区分:cookie 401 与错 token 401 的响应体都是纯文本
"unauthorized",而 webui 鉴权返回 JSON {"error":...}。

所以 GUI 只带 cookie 时:gateway 200、online 401 —— 同一连接同一
时刻,现象自相矛盾。

## 三种能工作的客户端做法一致,GUI 两样都没做
  · WebUI 浏览器:cookie 进内核,reverseToUpstream 注入
    deviceGatewayToken(handler_device.go:118)
  · waiter:客户端自己带 X-API-Key(gateway_discover.go:103)
  · GUI:既没注入、也没自带 ⇒ 设备页恒 401,且异常被 catch
    吞掉置空数组,界面与「确实没有设备」完全一样

## 改动
1) main.js:device-bridge:get 回传 token 明文(此前只有 tokenSet
   布尔,渲染进程永远拿不到)。明文本就存在本机 gui-prefs.json,
   不新增密钥存储面,且不落日志、不进 URL。
2) app.js:新增 isDeviceTokenPath / deviceApiKey,对设备接入面
   端点附加 X-API-Key(优先连接自身 apiKey,退回设备桥 token)。
3) app.js:新增 ensureDeviceToken,并**在首次 /device/* 请求之前**
   调用 —— 此前令牌加载排在 /device/online 之后,顺序正好反了。
4) app.js:401 时记下可读原因(deviceAuthHint),设备页顶部渲染
   红色提示卡,不再把鉴权失败伪装成「没有设备」。
5) app.js:网关发现加缓存,并从每 15s 的 refreshAll 轮询里移出
   (改为按需 + 切连接/手动刷新时重取)。网关地址是服务端配置的
   慢变量,15s 拉一次没有意义;此前它与 online 同秒重复调用。

## 实测(本机 Electron,192.168.2.60:8080,真实凭据)
  修复前:device/online 恒 401,设备列表空
  修复后:device/gateway 1 次(之后走缓存)
          device/online  每 15s,全部 200,返回 waiter-fnnas /
          waiter-mainnas 两台设备
          401 计数 0

判据:cmd/gui npm test 全绿。
This commit is contained in:
JianFeeeee
2026-10-01 20:24:55 +08:00
parent 53d9af446c
commit 60f51f40e0
2 changed files with 122 additions and 3 deletions

View File

@ -2630,6 +2630,12 @@ ipcMain.handle("device-bridge:get", () => {
authorized: !!db.authorized, // 客户端本地授权状态
gateway: db.gateway || "",
tokenSet: !!(db.token || ""),
// 设备接入面(/device/online 等)走服务端 requireToken,只认 X-API-Key。
// 渲染进程要用同一份令牌去拉设备列表,而此前只给了 tokenSet(布尔),
// 于是渲染侧永远拿不到明文 ⇒ 设备页恒 401(显示为空列表)。
// 明文本来就存在本机 gui-prefs.json,不新增任何密钥存储面;
// 且只经 contextBridge 送到渲染进程,不落日志、不进 URL。
token: db.token || "",
connected: !!deviceBridge,
deviceId: deviceBridgeId,
address: deviceBridgeAddr,

View File

@ -55,6 +55,7 @@ const state = {
displays: [],
selfDeviceId: "",
selfGateway: "",
deviceBridgeToken: "", // 设备接入令牌明文(来自 device-bridge:get),用于 /device/* 的 requireToken
// ---- 星图活动数据源(对齐服务端 /memory/graph/pulse + /runtime)----
starmapPulses: [], // { mesh, until, kind } 活动脉冲队列
starmapGrown: {}, // nodeId -> 生长动画截止时间戳(ms)
@ -511,6 +512,57 @@ function ApiError(message, status, statusText, body) {
}
ApiError.prototype = Object.create(Error.prototype);
// deviceTokenPaths 列出走**设备接入令牌**鉴权的端点。
//
// 为什么要单独判:服务端 /api/v1/device/ 命名空间下并存**两套鉴权**——
// · /device/gateway 注册在 webui 插件,走 requireAPI,认 session cookie
// · /device/online、/device/、/device/push
// 注册在 remotedevice 插件,走 requireToken,
// **只认 X-API-Key 头或 ?token=,完全不认 cookie**
// 所以只带 cookie 调后三者 ⇒ 401(实测:cookie 与错 token 的响应体
// 都是纯文本 "unauthorized",而 webui 鉴权是 JSON {"error":...},
// 可据此判定命中的是 requireToken)。
//
// 三种能工作的客户端做法一致:
// · WebUI 浏览器:cookie 进内核,reverseToUpstream 里注入
// deviceGatewayToken(handler_device.go:118)
// · waiter:客户端自己带 X-API-Key(gateway_discover.go:103)
// · GUI:两者都没做 ⇒ 设备页永远空白(本次修复的内容)
function isDeviceTokenPath(p) {
return (
p === "/device/online" ||
p === "/device" ||
p === "/device/push" ||
p.indexOf("/device/") === 0
) && p !== "/device/gateway";
}
// deviceApiKey 取设备接入令牌。
//
// 优先用连接自身的 apiKey(为该连接显式配的管理员凭据);
// 退回本机设备桥的 token(state.deviceBridgeToken,来自
// device-bridge:get,桥与 waiter 同源)——那才是 requireToken 要的那份。
// 两者都没有时返回空:此时发出去的请求会被服务端 401,属预期,
// 调用方负责给用户可读提示(见 deviceAuthHint)。
function deviceApiKey() {
if (state.currentConn && state.currentConn.apiKey)
return state.currentConn.apiKey;
return state.deviceBridgeToken || "";
}
// deviceAuthHint 在设备接口 401 时拼一句可读原因。
//
// 为什么不能静默:此前 401 被 catch 吞掉并置空数组,界面上与
// 「确实没有设备」长得一模一样,用户无从判断是配置问题还是真没设备。
function deviceAuthHint() {
if (state.currentConn && state.currentConn.apiKey) return "";
if (state.deviceBridgeToken) return "";
return __(
"设备接口需要设备接入令牌:请在「设置 → 设备」里配置设备桥网关与令牌,或给该连接填 API Key。",
"Device endpoints require a device token: configure the device bridge gateway and token in Settings → Devices, or set an API Key on this connection.",
);
}
async function api(p, o) {
if (!state.currentConn)
throw new Error(__("未选择连接", "No connection selected"));
@ -521,6 +573,11 @@ async function api(p, o) {
var to = opts.timeout || 8000;
var headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
if (state.currentConn.apiKey) headers["X-API-Key"] = state.currentConn.apiKey;
// 设备接入面需要设备令牌而非管理员会话 cookie(见 isDeviceTokenPath 注释)。
if (!headers["X-API-Key"] && isDeviceTokenPath(p)) {
var devKey = deviceApiKey();
if (devKey) headers["X-API-Key"] = devKey;
}
var ctl = new AbortController();
var timer = setTimeout(() => {
ctl.abort();
@ -644,6 +701,9 @@ async function loadDiscoveredGateway() {
state.discoveredGateway = "";
return;
}
// 已有值则跳过:网关地址在一次会话内基本不变,
// 而 refreshAll 会被 15s 定时器反复触发。
if (state.discoveredGateway) return;
try {
var d = await api("/device/gateway");
state.discoveredGateway =
@ -748,6 +808,29 @@ function renderAll() {
refreshAll();
}
// ensureDeviceToken 拉取设备桥配置并缓存设备令牌。
//
// ★ 必须在任何 /device/* 请求**之前**调用:那些端点走服务端
// requireToken,只认 X-API-Key;令牌不在手上就必然 401。
// (此前令牌加载排在 /device/online 之后,顺序正好反了。)
async function ensureDeviceToken() {
try {
if (!window.homeagent || !window.homeagent.deviceBridge) return "";
var dbinfo = await window.homeagent.deviceBridge.get();
state.dbConfig = dbinfo || state.dbConfig;
state.deviceBridgeToken = (dbinfo && dbinfo.token) || "";
// 顺带按需发现网关地址:设备通道配置(renderDevices 的 webuiUrl)
// 依赖 state.discoveredGateway,而它是服务端配置、客户端无从推导。
// loadDiscoveredGateway 自带缓存,这里不会反复请求。
try {
await loadDiscoveredGateway();
} catch (e) {}
return state.deviceBridgeToken;
} catch (e) {
return "";
}
}
async function refreshDataOnly() {
// 仅刷新 state 数据,不重建 DOM(用于定时轮询时避免擦掉用户输入)
try {
@ -786,14 +869,18 @@ async function refreshDataOnly() {
state.currentConn.type === "webui" &&
state.currentConn.url
) {
await loadDiscoveredGateway();
// 令牌必须先到位,否则 /device/online 必 401(见 ensureDeviceToken)。
await ensureDeviceToken();
var d = await api("/device/online");
state.devices = (d && d.devices) || [];
state.deviceAuthError = "";
} else {
state.devices = [];
}
} catch (e) {
// 静默置空会把「没鉴权」显示成「没设备」,两者界面上无法区分。
state.devices = [];
state.deviceAuthError = e && e.status === 401 ? deviceAuthHint() : "";
}
try {
try {
@ -871,14 +958,20 @@ async function refreshAll() {
state.currentConn.type === "webui" &&
state.currentConn.url
) {
await loadDiscoveredGateway();
// 网关发现**不在**这里做:doRenderAll 每 15s 会走到 refreshAll,
// 而网关地址是服务端配置(慢变量),15s 拉一次纯属浪费。
// 它由 ensureDeviceToken 同批按需拉取(下面 deviceRefresh/切连接时),
// 且有缓存:discoveredGateway 一旦拿到就不重复请求。
await ensureDeviceToken();
var d = await api("/device/online");
state.devices = (d && d.devices) || [];
state.deviceAuthError = "";
} else {
state.devices = [];
}
} catch (e) {
state.devices = [];
state.deviceAuthError = e && e.status === 401 ? deviceAuthHint() : "";
}
try {
// 本机显示器列表(screensue 默认屏幕配置用)
@ -5868,6 +5961,8 @@ async function saveConnForm() {
// 而定时器闭包里的 api() 会一直打旧地址。
starmapStopActivity();
starmapDirty = false;
// 切连接后网关地址要重新发现(缓存属于旧后端)
state.discoveredGateway = "";
state.starmapPulses = [];
state.starmapGrown = {};
state._smPrevSched = null;
@ -6339,6 +6434,20 @@ function renderDevices() {
if (!el) return;
var conn = state.currentConn;
var devs = state.devices || [];
// 设备列表鉴权失败的显式提示。
//
// 为什么必须显式:/device/online 走服务端 requireToken(只认
// X-API-Key),而普通面板用管理员 cookie。没配令牌时服务端返回 401,
// 旧实现把它 catch 掉置空数组 ⇒ 界面与「确实没有设备」完全一样,
// 用户只能看到空列表,无法判断是配置问题还是真没设备。
var authErrHtml = state.deviceAuthError
? '<div class="card" style="border-left:3px solid #d1383d">' +
'<h2>' +
__("设备列表不可用", "Device list unavailable") +
"</h2><p style=\"color:var(--text-muted)\">" +
escHtml(state.deviceAuthError) +
"</p></div>"
: "";
var selfDev = null;
if (state.selfDeviceId) {
for (var si = 0; si < devs.length; si++) {
@ -6615,7 +6724,7 @@ function renderDevices() {
html += "</table>";
}
html += "</div>";
el.innerHTML = html;
el.innerHTML = authErrHtml + html;
}
// 保存设备通道配置(网关 + token + 启用),调主进程 deviceBridge:set
@ -6672,6 +6781,10 @@ async function saveBridgeChannel() {
async function deviceRefresh() {
try {
// 手动刷新是显式动作:破掉网关地址缓存,确保拿到服务端当前值
state.discoveredGateway = "";
await loadDiscoveredGateway();
await ensureDeviceToken();
var d = await api("/device/online");
state.devices = d.devices || [];
renderDevices();