diff --git a/src/widgets/truenas/proxy.js b/src/widgets/truenas/proxy.js new file mode 100644 index 000000000..35416d183 --- /dev/null +++ b/src/widgets/truenas/proxy.js @@ -0,0 +1,177 @@ +import WebSocket from "ws"; + +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import { 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 buildWebsocketUrl(baseUrl) { + const url = new URL(baseUrl); + const pathname = url.pathname.replace(/\/$/, ""); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.pathname = `${pathname}/websocket`; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +function waitForMessage(ws, matcher) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("TrueNAS websocket request timed out")); + }, 10000); + + const handleMessage = (data) => { + try { + const parsed = JSON.parse(data.toString()); + const matchResult = matcher(parsed); + if (matchResult !== undefined) { + cleanup(); + if (matchResult instanceof Error) { + reject(matchResult); + } else { + resolve(matchResult); + } + } + } catch (err) { + cleanup(); + reject(err); + } + }; + + const handleError = (err) => { + cleanup(); + reject(err); + }; + + const handleClose = () => { + cleanup(); + reject(new Error("TrueNAS websocket closed the connection")); + }; + + function cleanup() { + clearTimeout(timeout); + ws.off("message", handleMessage); + ws.off("error", handleError); + ws.off("close", handleClose); + } + + ws.on("message", handleMessage); + ws.on("error", handleError); + ws.on("close", handleClose); + }); +} + +async function ensureConnected(ws) { + ws.send(JSON.stringify({ msg: "connect", version: "1", support: ["1"] })); + await waitForMessage(ws, (message) => (message?.msg === "connected" ? true : undefined)); +} + +let nextId = 1; +async function sendMethod(ws, method, params = []) { + const id = nextId++; + ws.send(JSON.stringify({ id, msg: "method", method, params })); + + return waitForMessage(ws, (message) => { + if (message?.msg === "result" && message.id === id) { + if (message.error) { + return new Error(message.error.reason || JSON.stringify(message.error)); + } + return message.result; + } + if (message?.msg === "error" && message.id === id) { + return new Error(message.error || "Unknown websocket error"); + } + return undefined; + }); +} + +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; + } + + throw new Error("TrueNAS authentication failed"); +} + +async function callWebsocket(widget, method) { + const wsUrl = buildWebsocketUrl(widget.url); + const ws = new WebSocket(wsUrl, { rejectUnauthorized: false }); + + await waitForMessage(ws, (message) => (message === "open" ? true : undefined)); + + try { + await ensureConnected(ws); + await authenticate(ws, widget); + const result = await sendMethod(ws, method); + return result; + } finally { + ws.close(); + } +} + +export default async function truenasProxyHandler(req, res, map) { + const { group, service, endpoint, index } = req.query; + + try { + if (!endpoint) { + return res.status(204).end(); + } + + const widget = await getServiceWidget(group, service, index); + const widgetVersion = Number(widget?.version ?? 1); + + if (Number.isNaN(widgetVersion) || widgetVersion < 2) { + // Use legacy REST proxy for version 1 + return credentialedProxyHandler(req, res, map); + } + + const widgetDefinition = widgets?.[widget.type]; + const mappingEntry = Object.values(widgetDefinition?.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 = await callWebsocket(widget, wsMethod); + + 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, falling back to REST: %s", err?.message ?? err); + return credentialedProxyHandler(req, res, map); + } + } catch (err) { + logger.error(err); + return res.status(500).json({ error: err?.message ?? "Unexpected TrueNAS error" }); + } +}