diff --git a/docs/widgets/services/truenas.md b/docs/widgets/services/truenas.md index 97bba3be7..96785af41 100644 --- a/docs/widgets/services/truenas.md +++ b/docs/widgets/services/truenas.md @@ -5,6 +5,11 @@ description: TrueNas Scale Widget Configuration Learn more about [TrueNas](https://www.truenas.com/). +| TrueNAS Version | Homepage widget version | +| ----------------------- | ----------------------- | +| < 26.04 (REST API) | 1 (default) | +| > 25.04 (Websocket API) | 2 | + Allowed fields: `["load", "uptime", "alerts"]`. To create an API Key, follow [the official TrueNAS documentation](https://www.truenas.com/docs/scale/scaletutorials/toptoolbar/managingapikeys/). @@ -17,6 +22,7 @@ To use the `enablePools` option with TrueNAS Core, the `nasType` parameter is re widget: type: truenas url: http://truenas.host.or.ip + version: 2 # optional, defaults to 1 username: user # not required if using api key password: pass # not required if using api key key: yourtruenasapikey # not required if using username / password diff --git a/package.json b/package.json index be58b9c7b..9fe4f0a55 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "tough-cookie": "^6.0.0", "urbackup-server-api": "^0.91.0", "winston": "^3.17.0", + "ws": "^8.18.3", "xml-js": "^1.6.11" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56b42b453..72f620316 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: winston: specifier: ^3.17.0 version: 3.17.0 + ws: + specifier: ^8.18.3 + version: 8.18.3 xml-js: specifier: ^1.6.11 version: 1.6.11 @@ -3011,8 +3014,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3345,7 +3348,7 @@ snapshots: '@types/tar': 6.1.13 '@types/ws': 8.5.14 form-data: 4.0.2 - isomorphic-ws: 5.0.0(ws@8.18.0) + isomorphic-ws: 5.0.0(ws@8.18.3) js-yaml: 4.1.1 jsonpath-plus: 10.3.0 node-fetch: 2.7.0 @@ -3355,7 +3358,7 @@ snapshots: tar: 7.4.3 tmp-promise: 3.0.3 tslib: 2.8.1 - ws: 8.18.0 + ws: 8.18.3 transitivePeerDependencies: - bufferutil - encoding @@ -5033,9 +5036,9 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@5.0.0(ws@8.18.0): + isomorphic-ws@5.0.0(ws@8.18.3): dependencies: - ws: 8.18.0 + ws: 8.18.3 iterator.prototype@1.1.5: dependencies: @@ -6215,7 +6218,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.18.0: {} + ws@8.18.3: {} xml-js@1.6.11: dependencies: diff --git a/src/widgets/truenas/component.jsx b/src/widgets/truenas/component.jsx index 12ceef564..712a5115b 100644 --- a/src/widgets/truenas/component.jsx +++ b/src/widgets/truenas/component.jsx @@ -12,8 +12,8 @@ export default function Component({ service }) { const { data: alertData, error: alertError } = useWidgetAPI(widget, "alerts"); const { data: statusData, error: statusError } = useWidgetAPI(widget, "status"); - const { data: poolsData, error: poolsError } = useWidgetAPI(widget, widget?.enablePools ? "pools" : null); - const { data: datasetData, error: datasetError } = useWidgetAPI(widget, widget?.enablePools ? "dataset" : null); + const { data: poolsData, error: poolsError } = useWidgetAPI(widget, widget?.enablePools ? "pools" : ""); + const { data: datasetData, error: datasetError } = useWidgetAPI(widget, widget?.enablePools ? "dataset" : ""); if (alertError || statusError || poolsError) { const finalError = alertError ?? statusError ?? poolsError ?? datasetError; diff --git a/src/widgets/truenas/proxy.js b/src/widgets/truenas/proxy.js new file mode 100644 index 000000000..ebc5299ef --- /dev/null +++ b/src/widgets/truenas/proxy.js @@ -0,0 +1,172 @@ +import WebSocket from "ws"; + +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import { formatApiCall, sanitizeErrorURL } from "utils/proxy/api-helpers"; +import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; +import validateWidgetData from "utils/proxy/validate-widget-data"; +import widgets from "widgets/widgets"; + +const logger = createLogger("truenasProxyHandler"); + +function waitForEvent(ws, handler, { event = "message", parseJson = true } = {}) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("TrueNAS websocket wait timed out")); + }, 10000); + + const handleEvent = (payload) => { + try { + let parsed = payload; + if (parseJson) { + if (Buffer.isBuffer(payload)) { + parsed = JSON.parse(payload.toString()); + } else if (typeof payload === "string") { + parsed = JSON.parse(payload); + } + logger.info("Received TrueNAS websocket message: %o", parsed); + } else { + logger.info("Received TrueNAS websocket message: %o", payload); + } + const handlerResult = handler(parsed); + if (handlerResult !== undefined) { + cleanup(); + if (handlerResult instanceof Error) { + reject(handlerResult); + } else { + resolve(handlerResult); + } + } + } catch (err) { + cleanup(); + reject(err); + } + }; + + const handleError = (err) => { + cleanup(); + logger.error("TrueNAS websocket error: %s", err?.message ?? err); + reject(err); + }; + + const handleClose = () => { + cleanup(); + logger.error("TrueNAS websocket connection closed unexpectedly"); + reject(new Error("TrueNAS websocket closed the connection")); + }; + + function cleanup() { + clearTimeout(timeout); + ws.off(event, handleEvent); + ws.off("error", handleError); + ws.off("close", handleClose); + } + + ws.on(event, handleEvent); + ws.on("error", handleError); + ws.on("close", handleClose); + }); +} + +let nextId = 1; +async function sendMethod(ws, method, params = []) { + const id = nextId++; + const payload = { jsonrpc: "2.0", id, method, params }; + logger.info("Sending TrueNAS websocket method %s with id %d", method, id); + ws.send(JSON.stringify(payload)); + + return waitForEvent(ws, (message) => { + if (message?.id !== id) return undefined; + if (message?.error) { + return new Error(message.error?.message || JSON.stringify(message.error)); + } + return message?.result ?? message; + }); +} + +async function authenticate(ws, widget) { + if (widget?.key) { + try { + const apiKeyResult = await sendMethod(ws, "auth.login_with_api_key", [widget.key]); + if (apiKeyResult === true) return; + logger.warn("TrueNAS API key authentication failed, falling back to username/password when available."); + } catch (err) { + logger.warn("TrueNAS API key authentication failed: %s", err?.message ?? err); + } + } + + if (widget?.username && widget?.password) { + const loginResult = await sendMethod(ws, "auth.login", [widget.username, widget.password]); + if (loginResult === true) return; + logger.warn("TrueNAS username/password authentication failed."); + } + + throw new Error("TrueNAS authentication failed"); +} + +export default async function truenasProxyHandler(req, res, map) { + const { group, service, endpoint, index } = req.query; + if (!group || !service) { + logger.debug("Invalid or missing service '%s' or group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const widget = await getServiceWidget(group, service, index); + + if (!widget) { + logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + if (!endpoint) { + return res.status(204).end(); + } + + const version = Number(widget.version ?? 1); + if (Number.isNaN(version) || version < 2) { + // Use legacy REST proxy for version 1 + return credentialedProxyHandler(req, res, map); + } + + const mappingEntry = Object.values(widgets[widget.type].mappings).find((mapping) => mapping.endpoint === endpoint); + const wsMethod = mappingEntry.wsMethod; + + if (!wsMethod) { + logger.debug("Missing wsMethod mapping for TrueNAS endpoint %s", endpoint); + return res.status(500).json({ error: "Missing wsMethod mapping." }); + } + + try { + let data; + const wsUrl = new URL(formatApiCall(widgets[widget.type].wsAPI, { ...widget })); + const useSecure = wsUrl.protocol === "https:" || Boolean(widget.key); // API key requires secure connection + if (useSecure && wsUrl.protocol !== "https:") + logger.info("Upgrading TrueNAS websocket connection to secure wss://"); + wsUrl.protocol = useSecure ? "wss:" : "ws:"; + logger.info("Connecting to TrueNAS websocket at %s", wsUrl); + const ws = new WebSocket(wsUrl, { rejectUnauthorized: false }); + await waitForEvent(ws, () => true, { event: "open", parseJson: false }); // wait for open + logger.info("Connected to TrueNAS websocket at %s", wsUrl); + try { + await authenticate(ws, widget); + data = await sendMethod(ws, wsMethod); + } finally { + ws.close(); + } + + if (!validateWidgetData(widget, endpoint, data)) { + return res.status(500).json({ error: { message: "Invalid data", url: sanitizeErrorURL(widget.url), data } }); + } + + if (map) data = map(data); + + return res.status(200).json(data); + } catch (err) { + if (err?.status) { + return res.status(err.status).json({ error: err.message }); + } + logger.warn("Websocket call for TrueNAS failed: %s", err?.message ?? err); + return res.status(500).json({ error: err?.message ?? "TrueNAS websocket call failed" }); + } +} diff --git a/src/widgets/truenas/widget.js b/src/widgets/truenas/widget.js index 528114edb..3f022b745 100644 --- a/src/widgets/truenas/widget.js +++ b/src/widgets/truenas/widget.js @@ -1,32 +1,43 @@ +import truenasProxyHandler from "./proxy"; + import { asJson, jsonArrayFilter } from "utils/proxy/api-helpers"; -import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; const widget = { api: "{url}/api/v2.0/{endpoint}", - proxyHandler: credentialedProxyHandler, + wsAPI: "{url}/api/current", + proxyHandler: truenasProxyHandler, mappings: { alerts: { endpoint: "alert/list", - map: (data) => ({ - pending: jsonArrayFilter(data, (item) => item?.dismissed === false).length, - }), + wsMethod: "alert.list", + map: (data) => { + if (Array.isArray(data)) { + return { pending: data.filter((item) => item?.dismissed === false).length }; + } + return { pending: jsonArrayFilter(data, (item) => item?.dismissed === false).length }; + }, }, status: { endpoint: "system/info", + wsMethod: "system.info", validate: ["loadavg", "uptime_seconds"], }, pools: { endpoint: "pool", - map: (data) => - asJson(data).map((entry) => ({ + wsMethod: "pool.query", + map: (data) => { + const list = Array.isArray(data) ? data : asJson(data); + return list.map((entry) => ({ id: entry.name, name: entry.name, healthy: entry.healthy, - })), + })); + }, }, dataset: { endpoint: "pool/dataset", + wsMethod: "pool.dataset.query", }, }, };