Compare commits

..

22 Commits

Author SHA1 Message Date
shamoon
a1a818727b Log websocket protocol upgrade
Some checks failed
Docker CI / Linting Checks (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
2026-01-06 17:56:58 -08:00
shamoon
0f5188b140 Minor refactoring 2026-01-06 17:56:58 -08:00
shamoon
6f2878b4f3 Enforce wss for api key 2026-01-06 17:56:58 -08:00
shamoon
9f1172ae9c A little more error handling 2026-01-06 17:56:58 -08:00
shamoon
7bee140166 Update proxy.js 2026-01-06 17:56:58 -08:00
shamoon
b45028c107 Remove this too 2026-01-06 17:56:58 -08:00
shamoon
ce74c974fc Refactor 2026-01-06 17:56:58 -08:00
shamoon
775c68675e Switch to /api/current as that seems less likely to be removed, use pure jsonrpc 2026-01-06 17:56:58 -08:00
shamoon
d24033dec2 Update truenas.md 2026-01-06 17:56:58 -08:00
shamoon
89e82c87ea Handle some JSON parsing issues 2026-01-06 17:56:58 -08:00
shamoon
67ee98ca73 Update TrueNAS widget documentation
Removed outdated API version table for TrueNAS widget.
2026-01-06 17:56:58 -08:00
shamoon
4b1cce7269 [revert] more logging 2026-01-06 17:56:58 -08:00
shamoon
b55aeba8e2 Update proxy.js 2026-01-06 17:56:58 -08:00
shamoon
f6b80550f0 UNify this 2026-01-06 17:56:58 -08:00
shamoon
408a96c355 Fix wait for open 2026-01-06 17:56:58 -08:00
shamoon
8502520904 Remove fallback 2026-01-06 17:56:58 -08:00
shamoon
e545fd7ac7 [revert me] Add some verbose logging 2026-01-06 17:56:58 -08:00
shamoon
7f08f48604 lint 2026-01-06 17:56:58 -08:00
shamoon
6ed2677687 Ok, truenas custom proxy 2026-01-06 17:56:58 -08:00
shamoon
2bc5e0de62 Setup for custom proxy 2026-01-06 17:56:58 -08:00
shamoon
e892432b72 doc 2026-01-06 17:56:58 -08:00
shamoon
189ee5742f Add explicit ws dep 2026-01-06 17:56:58 -08:00
6 changed files with 210 additions and 17 deletions

View File

@@ -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

View File

@@ -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": {

17
pnpm-lock.yaml generated
View File

@@ -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:

View File

@@ -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;

View File

@@ -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" });
}
}

View File

@@ -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",
},
},
};