mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
feat: 设备鉴权迁移至客户端 + 插件卸载保护
安全修复(客户端鉴权): - remotedevice 服务端移除授权状态存储(authorized map/SetAuthorized/handleDeviceAuth) - DeviceMeta.Authorized 改为设备 hello 自报,服务端仅透传展示 - device_ctl_* 工具移除服务端授权检查,无条件转发,设备端自行决定是否执行 - 共享设备桥库 Bridge 新增本地 authorized 状态,未授权收到 cmd 直接拒绝 - waiter: --device-authorized / device_authorized 配置控制本地授权 - GUI: 授权存 gui-prefs 本地文件;设备页仅本机可切换开关 - webui /device/auth 旧路径返回 410 Gone - 根因:agent 可经 config_set 篡改服务端授权配置自行授权设备 插件管理强化: - 内置插件禁止卸载(IsBuiltinPlugin + 409),外部插件卸载即时生效 - 卸载不存在插件返回 404;移除误导性 reload_required 提示 - webui 插件路由:名称白名单校验防路径穿越、保留字路径保护
This commit is contained in:
147
cmd/gui/main.js
147
cmd/gui/main.js
@ -1221,6 +1221,18 @@ function onDeviceMsg(msg) {
|
||||
);
|
||||
const op = msg.op || "";
|
||||
if (op === "cmd") {
|
||||
// 客户端鉴权:未授权时拒绝执行(授权状态存本地 gui-prefs,服务端无法篡改)
|
||||
if (!guiPrefs?.deviceBridge?.authorized) {
|
||||
console.log("[device-bridge] cmd rejected (unauthorized): req=" + (msg.req_id || ""));
|
||||
sendCmdResult({
|
||||
op: "cmd_result",
|
||||
req_id: msg.req_id || msg.id || "",
|
||||
device_id: deviceBridgeId,
|
||||
status: "error",
|
||||
error: "设备未授权:请在设备 GUI 设置页开启远程控制授权",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const command = msg.command || msg.cmd || "";
|
||||
const reqId = msg.req_id || msg.id || "";
|
||||
const cmdType = msg.cmd_type || "shell";
|
||||
@ -2057,6 +2069,7 @@ async function startDeviceBridge(cfg) {
|
||||
device_id: deviceBridgeId,
|
||||
name: "HomeAgent GUI",
|
||||
kind: "computer",
|
||||
authorized: guiPrefs?.deviceBridge?.authorized || false, // 客户端自报授权状态
|
||||
caps: [
|
||||
"status",
|
||||
"cmdrun",
|
||||
@ -2141,111 +2154,34 @@ function timeLeq(a, b) {
|
||||
return a <= b;
|
||||
}
|
||||
|
||||
// 执行授权开关(经 webui 反代 /api/v1/device/auth)
|
||||
// 执行授权开关(客户端本地鉴权:写 gui-prefs.deviceBridge.authorized,
|
||||
// 并重新 hello 同步自报状态到服务端展示。服务端不存储授权,agent 无法篡改。)
|
||||
async function setDeviceAuthorized(authorized) {
|
||||
try {
|
||||
// URL + cookie 优先 authRule;否则从 connections.json 取当前连接(webui 反代)
|
||||
let base = "";
|
||||
let cookie = "";
|
||||
if (authRule && authRule.url) {
|
||||
base = authRule.url.replace(/\/+$/, "");
|
||||
cookie =
|
||||
(authRule.cookie || "") +
|
||||
(authRule.slSession ? "; " + authRule.slSession : "");
|
||||
} else {
|
||||
try {
|
||||
const conns = loadConnections();
|
||||
const cur =
|
||||
conns.connections.find((c) => c.id === conns.currentId) ||
|
||||
conns.connections[0];
|
||||
if (cur && cur.url) {
|
||||
base = cur.url.replace(/\/+$/, "");
|
||||
cookie = cur.cookie || "";
|
||||
}
|
||||
} catch (e2) {}
|
||||
}
|
||||
if (!base || !cookie) {
|
||||
console.log("[auth-schedule] no base/cookie, skip");
|
||||
return;
|
||||
}
|
||||
const url = base + "/api/v1/device/auth";
|
||||
const body = JSON.stringify({
|
||||
device_id: deviceBridgeId,
|
||||
authorize: authorized,
|
||||
});
|
||||
// 用 node https 直连(带 cookie + 跟随 302),不走 Electron session(避免 jar 会话差异)
|
||||
const resp = await new Promise((resolve, reject) => {
|
||||
const https = require("https");
|
||||
const u = new URL(url);
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: u.hostname,
|
||||
port: u.port || 443,
|
||||
path: u.pathname + u.search,
|
||||
method: "POST",
|
||||
rejectUnauthorized: false,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
||||
Cookie: cookie,
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
},
|
||||
const prefs = loadGuiPrefs();
|
||||
prefs.deviceBridge = prefs.deviceBridge || {};
|
||||
prefs.deviceBridge.authorized = !!authorized;
|
||||
saveGuiPrefs(prefs);
|
||||
console.log("[auth] set local authorized=" + !!authorized);
|
||||
// 若设备桥已连接,重新 hello 同步状态
|
||||
if (deviceBridge && deviceBridge.send && deviceBridgeId) {
|
||||
deviceBridge.send({
|
||||
op: "hello",
|
||||
device: {
|
||||
device_id: deviceBridgeId,
|
||||
name: "HomeAgent GUI",
|
||||
kind: "computer",
|
||||
authorized: !!authorized,
|
||||
caps: [
|
||||
"status", "cmdrun", "deviceinfo", "cmdresult",
|
||||
"computeruse", "screensee", "clipboardsee", "clipboardsue",
|
||||
"speakeruse", "camerasue", "screensue", "omniparse",
|
||||
],
|
||||
},
|
||||
(res) => {
|
||||
let buf = "";
|
||||
res.on("data", (c) => (buf += c));
|
||||
res.on("end", () => {
|
||||
if (
|
||||
res.statusCode >= 300 &&
|
||||
res.statusCode < 400 &&
|
||||
res.headers.location
|
||||
) {
|
||||
// 跟随 302 到登录域后重试一次
|
||||
const loc = res.headers.location;
|
||||
const lurl = loc.startsWith("http")
|
||||
? loc
|
||||
: new URL(loc, url).toString();
|
||||
const req2 = https.request(
|
||||
{
|
||||
...u2opts(lurl),
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: cookie,
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(r2) => {
|
||||
let b2 = "";
|
||||
r2.on("data", (c) => (b2 += c));
|
||||
r2.on("end", () =>
|
||||
resolve({ status: r2.statusCode, body: b2 }),
|
||||
);
|
||||
},
|
||||
);
|
||||
req2.write(body);
|
||||
req2.end();
|
||||
return;
|
||||
}
|
||||
resolve({ status: res.statusCode, body: buf });
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
console.log(
|
||||
"[auth-schedule] set authorized=" +
|
||||
authorized +
|
||||
" -> HTTP " +
|
||||
resp.status +
|
||||
" " +
|
||||
resp.body.slice(0, 60),
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[auth-schedule] set authorized failed: " + e.message);
|
||||
console.error("[auth] set authorized failed: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2540,6 +2476,7 @@ function loadGuiPrefs() {
|
||||
exitToTray: d.exitToTray === undefined ? true : !!d.exitToTray,
|
||||
deviceBridge: {
|
||||
enabled: !!db.enabled,
|
||||
authorized: !!db.authorized, // 客户端本地授权(用户在设备上手动开启,服务端不存储)
|
||||
gateway: db.gateway || "",
|
||||
token: db.token || "",
|
||||
screensueDisplay: db.screensueDisplay || "0",
|
||||
@ -2557,6 +2494,7 @@ function loadGuiPrefs() {
|
||||
exitToTray: true,
|
||||
deviceBridge: {
|
||||
enabled: true,
|
||||
authorized: false, // 默认不授权,用户手动开启
|
||||
gateway: "",
|
||||
token: "",
|
||||
screensueDisplay: "0",
|
||||
@ -2629,6 +2567,7 @@ ipcMain.handle("device-bridge:get", () => {
|
||||
const db = p.deviceBridge || {};
|
||||
return {
|
||||
enabled: !!db.enabled,
|
||||
authorized: !!db.authorized, // 客户端本地授权状态
|
||||
gateway: db.gateway || "",
|
||||
tokenSet: !!(db.token || ""),
|
||||
connected: !!deviceBridge,
|
||||
@ -2641,6 +2580,12 @@ ipcMain.handle("device-bridge:get", () => {
|
||||
};
|
||||
});
|
||||
|
||||
// IPC:设置本机授权状态(客户端鉴权:仅写本地 prefs + 重发 hello,不经服务端)
|
||||
ipcMain.handle("device-bridge:setAuthorized", (_, auth) => {
|
||||
setDeviceAuthorized(!!auth);
|
||||
return { authorized: !!auth };
|
||||
});
|
||||
|
||||
// IPC:配置本机设备桥(开关+网关+token),保存并动态启停
|
||||
ipcMain.handle("device-bridge:set", (_, cfg) => {
|
||||
const cur = loadGuiPrefs();
|
||||
|
||||
@ -44,6 +44,8 @@ contextBridge.exposeInMainWorld("homeagent", {
|
||||
deviceBridge: {
|
||||
get: () => ipcRenderer.invoke("device-bridge:get"),
|
||||
set: (cfg) => ipcRenderer.invoke("device-bridge:set", cfg),
|
||||
setAuthorized: (auth) =>
|
||||
ipcRenderer.invoke("device-bridge:setAuthorized", auth),
|
||||
},
|
||||
displays: {
|
||||
list: () => ipcRenderer.invoke("displays:list"),
|
||||
|
||||
@ -825,6 +825,28 @@ async function refreshAll() {
|
||||
state.selfDeviceId = dbinfo.deviceId;
|
||||
state.selfGateway = dbinfo.address || state.selfGateway;
|
||||
}
|
||||
// 本机授权状态以客户端本地为准(服务端自报值仅展示)
|
||||
if (typeof dbinfo.authorized === "boolean") {
|
||||
var found = false;
|
||||
for (var di = 0; di < state.devices.length; di++) {
|
||||
if (state.devices[di].device_id === dbinfo.deviceId) {
|
||||
state.devices[di].authorized = dbinfo.authorized;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found && dbinfo.authorized) {
|
||||
// 服务端列表未含本机(可能离线),仍展示本地状态
|
||||
state.devices.push({
|
||||
device_id: dbinfo.deviceId,
|
||||
name: "HomeAgent GUI",
|
||||
kind: "computer",
|
||||
authorized: dbinfo.authorized,
|
||||
online: false,
|
||||
caps: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
// 若尚未有设备列表且已启用设备桥但非 device 连接,尝试经设备桥网关拉取
|
||||
if (
|
||||
state.devices.length === 0 &&
|
||||
@ -5295,19 +5317,20 @@ function renderDevices() {
|
||||
auth +
|
||||
"</td><td>" +
|
||||
escHtml(caps) +
|
||||
"</td><td>" +
|
||||
(d.authorized
|
||||
? '<label class="switch"><input type="checkbox" checked' +
|
||||
" onchange=\"deviceToggleAuth('" +
|
||||
d.device_id +
|
||||
"',this.checked)\"><span></span></label> " +
|
||||
__("已授权", "Yes")
|
||||
: '<label class="switch"><input type="checkbox"' +
|
||||
" onchange=\"deviceToggleAuth('" +
|
||||
d.device_id +
|
||||
"',this.checked)\"><span></span></label> " +
|
||||
__("未授权", "No")) +
|
||||
"</td></tr>";
|
||||
"</td><td>";
|
||||
// 客户端鉴权:只有本机设备可切换授权开关;其它设备的授权由其自身控制
|
||||
if (d.device_id === state.selfDeviceId) {
|
||||
html +=
|
||||
'<label class="switch"><input type="checkbox"' +
|
||||
(d.authorized ? " checked" : "") +
|
||||
" onchange=\"deviceToggleAuth('" +
|
||||
d.device_id +
|
||||
"',this.checked)\"><span></span></label>";
|
||||
} else {
|
||||
html += '<span style="color:var(--text-muted);font-size:12px">' +
|
||||
__("由该设备自行控制", "Controlled by device itself") + "</span>";
|
||||
}
|
||||
html += "</td></tr>";
|
||||
});
|
||||
html += "</table>";
|
||||
}
|
||||
@ -5379,11 +5402,15 @@ async function deviceRefresh() {
|
||||
|
||||
async function deviceToggleAuth(deviceID, auth) {
|
||||
try {
|
||||
await api("/device/auth", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ device_id: deviceID, authorize: auth }),
|
||||
});
|
||||
toast(__("授权已更新", "Authorization updated"));
|
||||
// 客户端鉴权:授权状态存在设备本地(gui-prefs),不经服务端,agent 无法篡改
|
||||
if (window.homeagent && window.homeagent.deviceBridge) {
|
||||
await window.homeagent.deviceBridge.setAuthorized(auth);
|
||||
}
|
||||
toast(
|
||||
__("本机授权已更新", "Local authorization updated") + " (" +
|
||||
(auth ? __("已授权", "Yes") : __("未授权", "No")) +
|
||||
")",
|
||||
);
|
||||
deviceRefresh();
|
||||
} catch (e) {
|
||||
toast(__("授权失败: ", "Auth failed: ") + e.message, true);
|
||||
|
||||
Reference in New Issue
Block a user