mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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:
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user