diff --git a/README.md b/README.md index 59ab243d..cd364f83 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Widgets and the services they read: - **DNS**: AdGuard, Pi-hole, Technitium, NextDNS - **GitHub**: contribution graph and pull requests - **Books**: Audiobookshelf, Komga, Kavita -- **System summary**: CPU, memory, disk, throughput, uptime, and network speed from SpeedTest Tracker or MySpeed. Reports on this machine, or on a host running Glances, Beszel, the Unraid API or Proxmox VE +- **System summary**: CPU, memory, disk, throughput, uptime, and network speed from SpeedTest Tracker or MySpeed. Reports on this machine, or on a host running Glances, Beszel, the Unraid API, Proxmox VE or TrueNAS - **Disk health**: TrueNAS, Unraid, Scrutiny - **Backup**: Duplicati, Kopia - **Connections**: Gluetun, Psiphon Conduit, Netbird, Plausible, Umami diff --git a/api/src/proxy.js b/api/src/proxy.js index f821212f..d75cf239 100644 --- a/api/src/proxy.js +++ b/api/src/proxy.js @@ -8,6 +8,7 @@ const { IS_DEMO } = require('./demo'); const { parseXml } = require('./parse-xml'); const log = require('./log'); const { parsePrometheus } = require('./parse-prometheus'); +const websocket = require('./websocket'); /* Addresses that are never a legitimate outbound target. */ /** @type {Array<[string, number, string]>} */ @@ -399,6 +400,107 @@ function fetchJSON(raw, opts = {}) { }); } +/** @param {string} raw + @param {{ headers?: Record, skipTls?: boolean, pinIp?: string|null, + maxBytes?: number, timeout?: number, signal?: AbortSignal }} [opts] */ +function openWebSocket(raw, opts = {}) { + return new Promise((resolve, reject) => { + let u; + try { + u = new URL(raw); + } catch (e) { + return reject(e); + } + const policy = urlPolicyError(u); + if (policy) return reject(Object.assign(new Error(policy), { kind: 'blocked', status: 403 })); + const signal = opts.signal; + if (signal?.aborted) return reject(signal.reason); + const secure = u.protocol === 'https:'; + const { skip: skipTls, ignored: skipIgnored } = resolveSkipTls(u.hostname, opts.skipTls); + const key = websocket.handshakeKey(); + /** @type {Record} */ + const headers = { + ...(opts.headers || {}), + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Key': key, + }; + const pin = opts.pinIp && opts.pinIp !== u.hostname ? opts.pinIp : null; + if (pin) headers.Host = u.host; + let settled = false; + const settle = (fn, arg) => { + if (settled) return; + settled = true; + signal?.removeEventListener('abort', onAbort); + fn(arg); + }; + const req = (secure ? https : http).request({ + hostname: pin || u.hostname.replace(/^\[|\]$/g, ''), + port: u.port || (secure ? 443 : 80), + path: u.pathname + u.search, + headers, + servername: pin ? u.hostname : undefined, + lookup: resolver.lookupWithin(opts.timeout || FETCH_MS), + rejectUnauthorized: !skipTls, + agent: false, + }); + function onAbort() { + req.destroy(); + settle(reject, signal?.reason); + } + signal?.addEventListener('abort', onAbort, { once: true }); + req.on('response', res => { + res.destroy(); + req.destroy(); + const sc = res.statusCode ?? 0; + settle( + reject, + Object.assign(new Error(`WebSocket upgrade refused (HTTP ${sc})`), { kind: 'upstream', status: sc }), + ); + }); + req.on('upgrade', (res, socket, head) => { + const accepted = + String(res.headers.upgrade || '').toLowerCase() === 'websocket' && + res.headers['sec-websocket-accept'] === websocket.acceptFor(key); + if (!accepted || settled) { + socket.destroy(); + return settle(reject, Object.assign(new Error('The WebSocket handshake failed.'), { kind: 'upstream' })); + } + const conn = websocket.wrapSocket(socket, head, { maxBytes: opts.maxBytes }); + signal?.addEventListener('abort', () => conn.destroy(signal.reason), { once: true }); + settle(resolve, conn); + }); + req.on('error', (/** @type {unknown} */ e) => { + if (skipIgnored && TLS_ERROR_CODES.has(errCode(e) ?? '')) { + /** @type {{ vouchedMessage?: string }} */ (e).vouchedMessage = SKIP_TLS_IGNORED_MESSAGE; + /** @type {{ apiCode?: string }} */ (e).apiCode = 'network.tls-ignored'; + } + settle(reject, e); + }); + req.end(); + }); +} + +/** @template T + @param {string} raw @param {NonNullable[1]>} opts + @param {(session: ReturnType) => Promise} fn @returns {Promise} */ +async function jsonRpc(raw, opts, fn) { + if (IS_DEMO) throw Object.assign(new Error('Outbound requests are disabled in demo mode'), { kind: 'blocked' }); + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(new Error('Timed out')), opts.timeout || FETCH_MS); + timer.unref(); + let conn = null; + try { + conn = await openWebSocket(raw, { ...opts, signal: ac.signal }); + const session = websocket.rpcSession(conn); + return await Promise.race([fn(session), session.failed]); + } finally { + clearTimeout(timer); + conn?.close(); + } +} + function statusDesc(code) { if (code === 0) return 'No response'; if (code < 400) return 'OK'; @@ -588,6 +690,20 @@ function fetchUnchecked(url, opts = {}) { return fetchJSON(rewriteUrl(url), opts); } +async function jsonRpcChecked(url, opts, fn) { + if (IS_DEMO) return jsonRpc(url, opts, fn); + const target = rewriteUrl(url); + const budget = opts.timeout || FETCH_MS; + const started = Date.now(); + const guard = await guardSsrf(target, budget); + if (guard.error) throw new SsrfBlockedError(guard.error, guard.reason); + return jsonRpc(target, { ...opts, pinIp: guard.ip, timeout: Math.max(1, budget - (Date.now() - started)) }, fn); +} + +function jsonRpcUnchecked(url, opts, fn) { + return jsonRpc(rewriteUrl(url), opts, fn); +} + function pingUnchecked(url, ms, skipTls) { return pingUrl(rewriteUrl(url), ms, skipTls); } @@ -618,6 +734,8 @@ async function pingChecked(url, ms, skipTls) { module.exports = { fetchChecked, fetchUnchecked, + jsonRpcChecked, + jsonRpcUnchecked, pingChecked, pingUnchecked, SsrfBlockedError, @@ -639,5 +757,5 @@ module.exports = { isBlockedIPv4, embeddedIPv4, BLOCKED_IPV4, - _internals: { fetchJSON, pingUrl, guardSsrf, withDeadline, PING_CODES }, + _internals: { fetchJSON, openWebSocket, jsonRpc, pingUrl, guardSsrf, withDeadline, PING_CODES }, }; diff --git a/api/src/websocket.js b/api/src/websocket.js new file mode 100644 index 00000000..c7cc7f77 --- /dev/null +++ b/api/src/websocket.js @@ -0,0 +1,296 @@ +const crypto = require('crypto'); + +/* Open connections only through proxy.js. It holds the outbound guard. */ + +const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; +const MESSAGE_LIMIT = 4 * 1024 * 1024; +const WRITE_BACKLOG_LIMIT = 64 * 1024; + +const OP = { CONTINUATION: 0, TEXT: 1, BINARY: 2, CLOSE: 8, PING: 9, PONG: 10 }; + +const protocolError = message => Object.assign(new Error(message), { kind: 'upstream' }); +const tooLarge = () => Object.assign(protocolError('Response too large'), { responseTooLarge: true }); + +const handshakeKey = () => crypto.randomBytes(16).toString('base64'); + +const acceptFor = key => + crypto + .createHash('sha1') + .update(key + GUID) + .digest('base64'); + +/** @param {number} opcode @param {Buffer} payload */ +function encodeFrame(opcode, payload) { + const len = payload.length; + const head = len < 126 ? 2 : len < 65536 ? 4 : 10; + const out = Buffer.alloc(head + 4 + len); + out[0] = 0x80 | opcode; + if (len < 126) out[1] = 0x80 | len; + else if (len < 65536) { + out[1] = 0x80 | 126; + out.writeUInt16BE(len, 2); + } else { + out[1] = 0x80 | 127; + out.writeBigUInt64BE(BigInt(len), 2); + } + const mask = crypto.randomBytes(4); + mask.copy(out, head); + for (let i = 0; i < len; i++) out[head + 4 + i] = payload[i] ^ mask[i & 3]; + return out; +} + +/** @param {number} limit + @param {(fin: boolean, opcode: number, payload: Buffer) => void} onFrame */ +function frameDecoder(limit, onFrame) { + /** @type {Buffer[]} */ + let chunks = []; + let size = 0; + let need = 2; + return (/** @type {Buffer} */ chunk) => { + chunks.push(chunk); + size += chunk.length; + if (size < need) return; + let buf = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, size); + for (;;) { + need = 2; + if (buf.length < 2) break; + if (buf[1] & 0x80) throw protocolError('The server sent a masked frame.'); + if (buf[0] & 0x70) throw protocolError('The server sent a frame with reserved bits set.'); + let len = buf[1] & 0x7f; + let off = 2; + if (len === 126) { + need = 4; + if (buf.length < need) break; + len = buf.readUInt16BE(2); + off = 4; + } else if (len === 127) { + need = 10; + if (buf.length < need) break; + const big = buf.readBigUInt64BE(2); + if (big > BigInt(limit)) throw tooLarge(); + len = Number(big); + off = 10; + } + if (len > limit) throw tooLarge(); + need = off + len; + if (buf.length < need) break; + const fin = (buf[0] & 0x80) !== 0; + const opcode = buf[0] & 0x0f; + const payload = buf.subarray(off, need); + buf = buf.subarray(need); + need = 2; + onFrame(fin, opcode, payload); + } + chunks = buf.length ? [buf] : []; + size = buf.length; + }; +} + +/** @param {import('net').Socket} socket @param {Buffer} head + @param {{ maxBytes?: number }} [opts] */ +function wrapSocket(socket, head, opts = {}) { + const limit = Number(opts.maxBytes) > 0 ? Number(opts.maxBytes) : MESSAGE_LIMIT; + /** @type {string[]} */ + const backlog = []; + /** @type {((text: string) => void)|null} */ + let onMessage = null; + /** @type {((err: Error|null) => void)|null} */ + let onClose = null; + /** @type {Error|null|undefined} */ + let closedWith; + let closed = false; + let closing = false; + /** @type {Buffer[]} */ + let parts = []; + let partsSize = 0; + let inMessage = false; + + const finish = (/** @type {Error|null} */ err) => { + if (closed) return; + closed = true; + closedWith = err; + socket.destroy(); + if (onClose) onClose(err); + }; + + const write = (/** @type {number} */ opcode, /** @type {Buffer} */ payload) => { + if (closed || !socket.writable) return; + socket.write(encodeFrame(opcode, payload)); + if (socket.writableLength > WRITE_BACKLOG_LIMIT) + finish(protocolError('The service is not reading what it is sent.')); + }; + + const decode = frameDecoder(limit, (fin, opcode, payload) => { + if (opcode >= OP.CLOSE) { + if (!fin || payload.length > 125) throw protocolError('The server sent a bad control frame.'); + if (opcode === OP.PING) write(OP.PONG, payload); + else if (opcode === OP.CLOSE) { + if (!closing) write(OP.CLOSE, payload.subarray(0, 2)); + finish(closing ? null : protocolError('The service closed the connection.')); + } else if (opcode !== OP.PONG) throw protocolError('The server sent an unknown frame type.'); + return; + } + if (opcode === OP.CONTINUATION) { + if (!inMessage) throw protocolError('The server sent a continuation with no message.'); + } else if (opcode === OP.TEXT || opcode === OP.BINARY) { + if (inMessage) throw protocolError('The server started a message inside another.'); + inMessage = true; + } else throw protocolError('The server sent an unknown frame type.'); + partsSize += payload.length; + if (partsSize > limit) throw tooLarge(); + parts.push(payload); + if (!fin) return; + const text = Buffer.concat(parts, partsSize).toString('utf8'); + parts = []; + partsSize = 0; + inMessage = false; + if (onMessage) onMessage(text); + else backlog.push(text); + }); + + const feed = (/** @type {Buffer} */ chunk) => { + if (closed) return; + try { + decode(chunk); + } catch (e) { + finish(/** @type {Error} */ (e)); + } + }; + + socket.on('data', feed); + socket.on('error', e => finish(e)); + socket.on('close', () => finish(closing ? null : protocolError('The service closed the connection.'))); + + const conn = { + /** @param {string} text */ + send(text) { + write(OP.TEXT, Buffer.from(text, 'utf8')); + }, + /** @param {(text: string) => void} fn */ + onMessage(fn) { + onMessage = fn; + for (const text of backlog.splice(0)) fn(text); + }, + /** @param {(err: Error|null) => void} fn */ + onClose(fn) { + onClose = fn; + if (closed) fn(closedWith ?? null); + }, + close() { + if (closed) return; + closing = true; + write(OP.CLOSE, Buffer.from([0x03, 0xe8])); + socket.end(); + /* A server that never answers the close must not hold the socket. */ + setTimeout(() => finish(null), 1000).unref(); + }, + /** @param {Error} err */ + destroy(err) { + finish(err); + }, + }; + if (head && head.length) feed(head); + return conn; +} + +/* Never put the service's text in the message. Logs print it. */ +function rpcError(error) { + const code = Number.isInteger(error?.code) ? error.code : null; + return Object.assign(new Error(`JSON-RPC error ${code ?? 'without a code'}`), { + kind: 'upstream', + rpc: { code, message: error?.message, data: error?.data }, + }); +} + +/* Keep the catch. Every pending promise rejects when the connection ends. An + unhandled rejection exits the process. */ +const handled = (/** @type {Promise} */ p) => { + p.catch(() => {}); + return p; +}; + +/** @param {ReturnType} conn */ +function rpcSession(conn) { + let nextId = 1; + /** @type {Map void, reject: (e: Error) => void }>} */ + const pending = new Map(); + /** @type {Set<{ method: string, match: (p: any) => boolean, resolve: (v: any) => void, reject: (e: Error) => void }>} */ + const waiters = new Set(); + /** @type {Error|null} */ + let ended = null; + /** @type {(e: Error) => void} */ + let endWith = () => {}; + /** @type {Promise} */ + const failed = new Promise((_, reject) => { + endWith = reject; + }); + failed.catch(() => {}); + + conn.onMessage(text => { + let msg; + try { + msg = JSON.parse(text); + } catch { + return conn.destroy(protocolError('The service sent a message that is not JSON.')); + } + if (!msg || typeof msg !== 'object') return; + if (typeof msg.id === 'number' && pending.has(msg.id)) { + const p = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) p?.reject(rpcError(msg.error)); + else p?.resolve(msg.result); + return; + } + if (typeof msg.method !== 'string') return; + for (const w of [...waiters]) { + if (w.method !== msg.method || !w.match(msg.params)) continue; + waiters.delete(w); + w.resolve(msg.params); + } + }); + + conn.onClose(err => { + ended = err || protocolError('The connection closed.'); + for (const p of pending.values()) p.reject(ended); + pending.clear(); + for (const w of waiters) w.reject(ended); + waiters.clear(); + endWith(ended); + }); + + return { + /** @param {string} method @param {unknown[]} [params] @returns {Promise} */ + call(method, params = []) { + if (ended) return Promise.reject(ended); + const id = nextId++; + return handled( + new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + conn.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })); + }), + ); + }, + /** @param {string} method @param {(params: any) => boolean} [match] @returns {Promise} */ + notification(method, match = () => true) { + if (ended) return Promise.reject(ended); + return handled( + new Promise((resolve, reject) => { + waiters.add({ method, match, resolve, reject }); + }), + ); + }, + /* Rejects when the connection ends. Never resolves. */ + failed, + }; +} + +module.exports = { + handshakeKey, + acceptFor, + encodeFrame, + frameDecoder, + wrapSocket, + rpcSession, + MESSAGE_LIMIT, + OP, +}; diff --git a/api/src/widget-data.js b/api/src/widget-data.js index 1fc5bab7..e03182b5 100644 --- a/api/src/widget-data.js +++ b/api/src/widget-data.js @@ -1,7 +1,7 @@ const path = require('path'); const { on, json, readBody, checkOrigin, getIp } = require('./router'); const { loadConfig } = require('./config'); -const { fetchChecked, fetchUnchecked, SsrfBlockedError } = require('./proxy'); +const { fetchChecked, fetchUnchecked, jsonRpcChecked, jsonRpcUnchecked, SsrfBlockedError } = require('./proxy'); const { parsePrometheus } = require('./parse-prometheus'); const { cpuSample, ramPercent, cpuTemp, diskStats, procCount, uptimeSeconds } = require('./metrics'); const { getRegistry, WIDGETS_PATH } = require('./widgets'); @@ -52,13 +52,14 @@ const refuseUnreadable = const withinBudget = (fetch, deadline) => - (url, opts = {}) => { + (url, opts = {}, ...rest) => { const left = deadline - Date.now(); if (left <= 0) return Promise.reject(new Error('Timed out')); - return fetch(url, { ...opts, timeout: Math.min(opts.timeout || FETCH_MS, left) }); + return fetch(url, { ...opts, timeout: Math.min(opts.timeout || FETCH_MS, left) }, ...rest); }; function dataFnContext(wc, endpoint, searchParams, fetch, row = null) { + const deadline = Date.now() + WIDGET_BUDGET_MS; const ctx = { config: wc /* full widgetConfig, including secrets (server-side only) */, /* Non-secret keys only. The full settings object carries the session signing @@ -69,7 +70,9 @@ function dataFnContext(wc, endpoint, searchParams, fetch, row = null) { params: searchParams, /* The caller supplies the fetcher to match the URL's provenance: unchecked for saved config, checked for a request-supplied preview config. */ - fetchJSON: refuseUnreadable(withinBudget(fetch, Date.now() + WIDGET_BUDGET_MS)), + fetchJSON: refuseUnreadable(withinBudget(fetch, deadline)), + /* Follows the fetcher's provenance. Anything but saved config is checked. */ + jsonRpc: withinBudget(fetch === fetchUnchecked ? jsonRpcUnchecked : jsonRpcChecked, deadline), parsePrometheus, metrics: IS_DEMO ? demoData.metrics : { cpuSample, ramPercent, cpuTemp, diskStats, procCount, uptimeSeconds }, demo: IS_DEMO ? demoData.helpers : null, diff --git a/api/test-support/fake-json-rpc.js b/api/test-support/fake-json-rpc.js new file mode 100644 index 00000000..9f888346 --- /dev/null +++ b/api/test-support/fake-json-rpc.js @@ -0,0 +1,51 @@ +/* A stand-in for ctx.jsonRpc. `methods` maps a method name to its result, or + to a function of the params that returns or throws one. `events` maps an + event name to the params of the notification core.subscribe sends for it. */ + +const denied = () => + Object.assign(new Error('JSON-RPC error -32001'), { + kind: 'upstream', + rpc: { code: -32001, data: { errname: 'EACCES' } }, + }); + +function fakeJsonRpc(methods = {}, events = {}) { + const calls = []; + const opened = []; + const jsonRpc = async (url, opts, fn) => { + opened.push({ url, opts }); + /** @type {Array<{ method: string, match: Function, resolve: Function, reject: Function }>} */ + const waiters = []; + const session = { + async call(method, params = []) { + calls.push({ method, params }); + if (method === 'core.subscribe' && params[0] in events) { + const p = events[params[0]]; + setImmediate(() => { + for (const w of waiters.splice(0)) if (w.method === 'collection_update' && w.match(p)) w.resolve(p); + }); + return 'sub-1'; + } + if (!(method in methods)) + throw Object.assign(new Error('JSON-RPC error -32601'), { kind: 'upstream', rpc: { code: -32601 } }); + const m = methods[method]; + return typeof m === 'function' ? m(params) : m; + }, + notification(method, match = () => true) { + const p = new Promise((resolve, reject) => waiters.push({ method, match, resolve, reject })); + p.catch(() => {}); + return p; + }, + }; + try { + return await fn(session); + } finally { + for (const w of waiters.splice(0)) w.reject(new Error('The connection closed.')); + } + }; + return { jsonRpc, calls, opened }; +} + +const refusedUpgrade = status => + Object.assign(new Error(`WebSocket upgrade refused (HTTP ${status})`), { kind: 'upstream', status }); + +module.exports = { fakeJsonRpc, denied, refusedUpgrade }; diff --git a/api/test/disk-health-data.test.js b/api/test/disk-health-data.test.js index 60a8fd6f..4e5900eb 100644 --- a/api/test/disk-health-data.test.js +++ b/api/test/disk-health-data.test.js @@ -278,3 +278,125 @@ test('with no link set the widget opens the Unraid address the browser can reach assert.equal(r.href, 'http://10.0.0.9'); assert.equal(r.provider, 'unraid'); }); + +const { fakeJsonRpc, refusedUpgrade } = require('../test-support/fake-json-rpc'); + +const TRUENAS_KEY = '1-Qm9vdGVkVGhlS2V5'; +const POOLS = [ + { name: 'tank', healthy: true, size: 4000 }, + { name: 'backup', healthy: false, size: 1000 }, +]; + +function truenasCtx(endpoint, config, { methods, jsonRpc, rest } = {}) { + const rpc = fakeJsonRpc( + methods || { 'auth.login_with_api_key': ([key]) => key === TRUENAS_KEY, 'pool.query': POOLS }, + ); + const fetched = []; + const ctx = makeCtx( + endpoint, + { diskProvider: 'truenas', truenasUrl: 'nas.local', truenasKey: TRUENAS_KEY, ...config }, + async (url, opts) => { + fetched.push({ url, opts }); + return rest ? rest(url) : { status: 404, data: null }; + }, + ); + ctx.jsonRpc = jsonRpc || rpc.jsonRpc; + return { ctx, fetched, ...rpc }; +} + +test('TrueNAS pools are read over the WebSocket API at an https address', async () => { + const { ctx, opened, fetched } = truenasCtx('', { bays: ['tank', 'backup'], truenasSkipTls: true }); + const r = await dataFn(ctx); + assert.equal(opened[0].url, 'https://nas.local/api/current'); + assert.equal(opened[0].opts.skipTls, true); + assert.equal(fetched.length, 0); + assert.deepEqual( + r.bays.map(b => [b.device_id, b.device_status]), + [ + ['tank', 0], + ['backup', 2], + ], + ); + assert.equal(r.href, 'https://nas.local'); +}); + +test('the TrueNAS bay picker lists pools from the WebSocket API', async () => { + const { ctx } = truenasCtx('devices', {}); + assert.deepEqual((await dataFn(ctx)).options, [ + { value: 'tank', label: 'tank' }, + { value: 'backup', label: 'backup' }, + ]); +}); + +test('an http TrueNAS address is refused before the key is sent', async () => { + const { ctx, opened, fetched } = truenasCtx('', { truenasUrl: 'http://nas.local', bays: ['tank'] }); + await assert.rejects( + () => dataFn(ctx), + e => e.kind === 'invalid' && /https/.test(e.message), + ); + assert.equal(opened.length + fetched.length, 0); +}); + +test('a refused TrueNAS key is an auth error and REST is not tried', async () => { + const { ctx, fetched } = truenasCtx('', { truenasKey: '1-wrong', bays: ['tank'] }); + await assert.rejects( + () => dataFn(ctx), + e => e.kind === 'auth', + ); + assert.equal(fetched.length, 0); +}); + +test('TrueNAS before 25.04 falls back to REST with the same key', async () => { + const { ctx, fetched } = truenasCtx( + '', + { bays: ['tank'] }, + { + jsonRpc: async () => { + throw refusedUpgrade(404); + }, + rest: () => ({ status: 200, data: POOLS }), + }, + ); + const r = await dataFn(ctx); + assert.equal(fetched[0].url, 'https://nas.local/api/v2.0/pool'); + assert.equal(fetched[0].opts.headers.Authorization, 'Bearer ' + TRUENAS_KEY); + assert.equal(r.bays[0].device_status, 0); +}); + +test('a network failure on the WebSocket API does not fall back to REST', async () => { + const { ctx, fetched } = truenasCtx( + '', + { bays: ['tank'] }, + { + jsonRpc: async () => { + throw Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }); + }, + }, + ); + await assert.rejects( + () => dataFn(ctx), + e => e.code === 'ECONNREFUSED', + ); + assert.equal(fetched.length, 0); +}); + +test('an address with neither TrueNAS API is named as not TrueNAS', async () => { + const { ctx } = truenasCtx( + '', + { bays: ['tank'] }, + { + jsonRpc: async () => { + throw refusedUpgrade(404); + }, + }, + ); + await assert.rejects( + () => dataFn(ctx), + e => /not TrueNAS/.test(e.message), + ); +}); + +test('a TrueNAS pool that is gone reads as not found', async () => { + const { ctx } = truenasCtx('', { bays: ['gone'] }); + assert.equal((await dataFn(ctx)).bays[0].error, 'not found'); +}); diff --git a/api/test/system-summary-truenas-data.test.js b/api/test/system-summary-truenas-data.test.js new file mode 100644 index 00000000..80ab2af1 --- /dev/null +++ b/api/test/system-summary-truenas-data.test.js @@ -0,0 +1,178 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { normalizeBase } = require('../src/widget-data'); +const { dispatchProvider } = require('../src/provider-dispatch'); +const { errorParts } = require('../test-support/widget-ctx'); +const { fakeJsonRpc, denied, refusedUpgrade } = require('../test-support/fake-json-rpc'); + +const dataFn = require(path.join(__dirname, '..', '..', 'ui', 'widgets', 'system-summary', 'data.js')); +const { serviceBase, serviceLink } = require('../../ui/js/service-link.js'); + +const GB = 1024 ** 3; +const KEY = '1-Qm9vdGVkVGhlS2V5'; + +const REALTIME = { + collection: 'reporting.realtime', + fields: { + cpu: { cpu: { usage: 12.5, temp: null }, cpu0: { usage: 10, temp: 41 }, cpu1: { usage: 15, temp: 47 } }, + memory: { physical_memory_total: 32 * GB, physical_memory_available: 12 * GB, arc_size: 12 * GB }, + }, +}; + +const METHODS = { + 'auth.login_with_api_key': ([key]) => key === KEY, + 'pool.query': [{ name: 'tank' }, { name: 'backup' }], + 'pool.dataset.query': ([[[, , names]]]) => + [ + { id: 'tank', used: { parsed: 3 * 1024 * GB }, available: { parsed: 1024 * GB } }, + { id: 'backup', used: { parsed: 100 * GB }, available: { parsed: 900 * GB } }, + ].filter(d => names.includes(d.id)), + 'system.info': { uptime_seconds: 90061.34, uptime: '1 day, 1:01:01.340005' }, +}; + +function ctxFor(config = {}, methods = METHODS, events = { 'reporting.realtime': REALTIME }) { + const rpc = fakeJsonRpc(methods, events); + const ctx = { + endpoint: 'system', + config: { + statProvider: 'truenas', + truenasUrl: 'truenas.local', + truenasKey: KEY, + slots: [{ type: 'cpu' }, { type: 'ram' }, { type: 'disk', primary: 'tank', secondary: 'backup' }], + ...config, + }, + normalizeBase, + serviceBase, + serviceLink, + jsonRpc: typeof methods === 'function' ? methods : rpc.jsonRpc, + fetchJSON: async () => assert.fail('TrueNAS stats never use REST'), + ...errorParts(), + }; + ctx.dispatchProvider = (handlers, opts) => dispatchProvider(ctx, handlers, opts); + return { ctx, ...rpc }; +} + +test('a bare address gets https and the WebSocket API path', async () => { + const { ctx, opened } = ctxFor({ truenasSkipTls: true }); + await dataFn(ctx); + assert.equal(opened[0].url, 'https://truenas.local/api/current'); + assert.equal(opened[0].opts.skipTls, true); +}); + +test('an http address is refused before the key is sent', async () => { + const { ctx, opened } = ctxFor({ truenasUrl: 'http://truenas.local' }); + await assert.rejects( + () => dataFn(ctx), + e => e.kind === 'invalid' && /https/.test(e.message), + ); + assert.equal(opened.length, 0); +}); + +test('CPU, memory without the ZFS cache, and temperature come from the realtime event', async () => { + const { ctx } = ctxFor({ slots: [{ type: 'cpu' }, { type: 'ram' }, { type: 'temp' }] }); + const r = await dataFn(ctx); + assert.equal(r.cpu, 12.5); + assert.equal(r.ram, 25); + assert.equal(r.temp, 47); + assert.deepEqual(r.temps, { 0: 47 }); + assert.equal(r.iowait, null); + assert.equal(r.procs, null); +}); + +test('the package temperature wins over the hottest core when TrueNAS sends one', async () => { + const event = structuredClone(REALTIME); + event.fields.cpu.cpu.temp = 44; + const { ctx } = ctxFor({}, METHODS, { 'reporting.realtime': event }); + assert.equal((await dataFn(ctx)).temp, 44); +}); + +test('a pool slot reads its top dataset, used against used plus available', async () => { + const { ctx } = ctxFor(); + const r = await dataFn(ctx); + assert.deepEqual(r.disks, [ + { mount: 'tank', usedPct: 75, totalGb: 4096 }, + { mount: 'backup', usedPct: 10, totalGb: 1000 }, + ]); +}); + +test('an empty pool slot reports as / and reads the first pool', async () => { + const { ctx, calls } = ctxFor({ slots: [{ type: 'cpu' }, { type: 'ram' }, { type: 'disk', primary: '' }] }); + const r = await dataFn(ctx); + assert.deepEqual(r.disks, [{ mount: '/', usedPct: 75, totalGb: 4096 }]); + assert.ok(calls.some(c => c.method === 'pool.query')); +}); + +test('a pool that does not exist reads as empty, like the other sources', async () => { + const { ctx } = ctxFor({ slots: [{ type: 'disk', primary: 'nope' }, { type: 'cpu' }, { type: 'ram' }] }); + assert.deepEqual((await dataFn(ctx)).disks, [{ mount: 'nope', usedPct: 0, totalGb: 0 }]); +}); + +test('no disk slot asks for no pool data', async () => { + const { ctx, calls } = ctxFor({ slots: [{ type: 'cpu' }, { type: 'ram' }, { type: 'temp' }] }); + await dataFn(ctx); + assert.ok(!calls.some(c => c.method.startsWith('pool.'))); +}); + +test('uptime is read only when the network row shows it', async () => { + const off = ctxFor(); + assert.equal((await dataFn(off.ctx)).uptime, null); + assert.ok(!off.calls.some(c => c.method === 'system.info')); + + const on = ctxFor({ network: { enabled: true, mode: 'uptime' } }); + assert.equal((await dataFn(on.ctx)).uptime, 90061); +}); + +test('uptime stays empty when the key lacks the read-only admin role', async () => { + const methods = { + ...METHODS, + 'system.info': () => { + throw denied(); + }, + }; + const { ctx } = ctxFor({ network: { enabled: true, mode: 'uptime' } }, methods); + const r = await dataFn(ctx); + assert.equal(r.uptime, null); + assert.equal(r.cpu, 12.5); +}); + +test('a refused key is an auth error', async () => { + const { ctx, calls } = ctxFor({ truenasKey: '1-wrong' }); + await assert.rejects( + () => dataFn(ctx), + e => e.kind === 'auth' && /refused the API key/.test(e.message), + ); + assert.deepEqual( + calls.map(c => c.method), + ['auth.login_with_api_key'], + ); +}); + +test('a key without the reporting role names the roles it needs', async () => { + const methods = { + ...METHODS, + 'core.subscribe': () => { + throw denied(); + }, + }; + const { ctx } = ctxFor({}, methods, {}); + await assert.rejects( + () => dataFn(ctx), + e => e.kind === 'auth' && /REPORTING_READ, POOL_READ and DATASET_READ roles/.test(e.message), + ); +}); + +test('a server with no WebSocket API is named as too old or not TrueNAS', async () => { + const { ctx } = ctxFor({}, async () => { + throw refusedUpgrade(404); + }); + await assert.rejects( + () => dataFn(ctx), + e => /not TrueNAS 25\.04 or later/.test(e.message), + ); +}); + +test('the tile links to the TrueNAS address', async () => { + const { ctx } = ctxFor(); + assert.equal((await dataFn(ctx)).link, 'https://truenas.local'); +}); diff --git a/api/test/websocket.test.js b/api/test/websocket.test.js new file mode 100644 index 00000000..55465686 --- /dev/null +++ b/api/test/websocket.test.js @@ -0,0 +1,305 @@ +const { tmpPath } = require('../test-support/tmp'); +process.env.CONFIG_PATH = tmpPath('apps.json'); + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); +const crypto = require('node:crypto'); +const { jsonRpcChecked, jsonRpcUnchecked, SsrfBlockedError, _internals } = require('../src/proxy'); +const { acceptFor, frameDecoder, encodeFrame, OP } = require('../src/websocket'); + +function serverFrame(opcode, payload, fin = true) { + const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload); + const len = body.length; + const head = len < 126 ? 2 : len < 65536 ? 4 : 10; + const out = Buffer.alloc(head + len); + out[0] = (fin ? 0x80 : 0) | opcode; + if (len < 126) out[1] = len; + else if (len < 65536) { + out[1] = 126; + out.writeUInt16BE(len, 2); + } else { + out[1] = 127; + out.writeBigUInt64BE(BigInt(len), 2); + } + body.copy(out, head); + return out; +} + +function unmask(frame) { + const len = frame[1] & 0x7f; + const off = len === 126 ? 4 : len === 127 ? 10 : 2; + const mask = frame.subarray(off, off + 4); + const body = Buffer.from(frame.subarray(off + 4)); + for (let i = 0; i < body.length; i++) body[i] ^= mask[i & 3]; + return { opcode: frame[0] & 0x0f, masked: (frame[1] & 0x80) !== 0, body }; +} + +function wsServer(t, { onMessage = () => {}, accept, refuse, onControl } = {}) { + return new Promise(resolve => { + const s = http.createServer((_, res) => { + res.writeHead(refuse || 404); + res.end(); + }); + s.on('upgrade', (req, socket) => { + if (refuse) { + socket.end(`HTTP/1.1 ${refuse} No\r\nContent-Length: 0\r\n\r\n`); + return; + } + const key = req.headers['sec-websocket-key']; + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${accept ?? acceptFor(key)}\r\n\r\n`, + ); + let buf = Buffer.alloc(0); + socket.on('data', chunk => { + buf = Buffer.concat([buf, chunk]); + while (buf.length >= 2) { + const len = buf[1] & 0x7f; + const off = len === 126 ? 4 : len === 127 ? 10 : 2; + const size = len === 126 ? buf.readUInt16BE(2) : len === 127 ? Number(buf.readBigUInt64BE(2)) : len; + if (buf.length < off + 4 + size) return; + const frame = unmask(buf.subarray(0, off + 4 + size)); + buf = buf.subarray(off + 4 + size); + if (frame.opcode === OP.TEXT) onMessage(frame.body.toString(), socket); + else if (onControl) onControl(frame, socket); + else if (frame.opcode === OP.CLOSE) socket.end(serverFrame(OP.CLOSE, frame.body)); + } + }); + socket.on('error', () => {}); + }); + s.listen(0, '127.0.0.1', () => { + t.after(() => s.close()); + resolve(`http://127.0.0.1:${s.address().port}/api/current`); + }); + }); +} + +const reply = (socket, obj) => socket.write(serverFrame(OP.TEXT, JSON.stringify(obj))); + +const echoRpc = (text, socket) => { + const msg = JSON.parse(text); + if (msg.method === 'fail') + reply(socket, { jsonrpc: '2.0', id: msg.id, error: { code: -32001, data: { errname: 'EACCES' } } }); + else reply(socket, { jsonrpc: '2.0', id: msg.id, result: { method: msg.method, params: msg.params } }); +}; + +test('a call gets the result with its own id', async t => { + const url = await wsServer(t, { onMessage: echoRpc }); + const out = await jsonRpcUnchecked(url, { timeout: 3000 }, async s => [await s.call('a.b', [1]), await s.call('c')]); + assert.deepEqual(out, [ + { method: 'a.b', params: [1] }, + { method: 'c', params: [] }, + ]); +}); + +test('an error reply rejects with the service fields on rpc, not in the message', async t => { + const url = await wsServer(t, { onMessage: echoRpc }); + await assert.rejects( + () => jsonRpcUnchecked(url, { timeout: 3000 }, s => s.call('fail')), + e => e.rpc.code === -32001 && e.rpc.data.errname === 'EACCES' && !/EACCES/.test(e.message), + ); +}); + +test('notification waits for a matching one', async t => { + const url = await wsServer(t, { + onMessage: (text, socket) => { + const msg = JSON.parse(text); + reply(socket, { jsonrpc: '2.0', id: msg.id, result: 'sub-1' }); + reply(socket, { jsonrpc: '2.0', method: 'collection_update', params: { collection: 'other' } }); + reply(socket, { jsonrpc: '2.0', method: 'collection_update', params: { collection: 'wanted', n: 2 } }); + }, + }); + const params = await jsonRpcUnchecked(url, { timeout: 3000 }, async s => { + const next = s.notification('collection_update', p => p?.collection === 'wanted'); + await s.call('core.subscribe', ['wanted']); + return next; + }); + assert.deepEqual(params, { collection: 'wanted', n: 2 }); +}); + +test('a message split over fragments and a 64 KiB frame both arrive whole', async t => { + const big = 'x'.repeat(70000); + const url = await wsServer(t, { + onMessage: (text, socket) => { + const msg = JSON.parse(text); + const body = JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: msg.method === 'big' ? big : 'joined' }); + if (msg.method === 'big') return socket.write(serverFrame(OP.TEXT, body)); + const cut = Math.floor(body.length / 2); + socket.write(serverFrame(OP.TEXT, body.slice(0, cut), false)); + socket.write(serverFrame(OP.PING, 'hi')); + socket.write(serverFrame(OP.CONTINUATION, body.slice(cut))); + }, + }); + const out = await jsonRpcUnchecked(url, { timeout: 3000 }, async s => [await s.call('split'), await s.call('big')]); + assert.equal(out[0], 'joined'); + assert.equal(out[1].length, big.length); +}); + +test('a ping is answered with a pong carrying the same payload', async t => { + let pong = null; + const url = await wsServer(t, { + onMessage: (text, socket) => { + socket.write(serverFrame(OP.PING, 'beat')); + setTimeout(() => reply(socket, { jsonrpc: '2.0', id: JSON.parse(text).id, result: true }), 50); + }, + onControl: (frame, socket) => { + if (frame.opcode === OP.PONG) pong = frame; + if (frame.opcode === OP.CLOSE) socket.end(); + }, + }); + await jsonRpcUnchecked(url, { timeout: 3000 }, s => s.call('x')); + assert.equal(pong.masked, true); + assert.equal(pong.body.toString(), 'beat'); +}); + +test('a message over the size limit ends the session', async t => { + const url = await wsServer(t, { onMessage: (_, socket) => socket.write(serverFrame(OP.TEXT, 'y'.repeat(2048))) }); + await assert.rejects( + () => jsonRpcUnchecked(url, { timeout: 3000, maxBytes: 1024 }, s => s.call('x')), + e => e.responseTooLarge === true, + ); +}); + +test('a masked frame from the server ends the session', async t => { + const url = await wsServer(t, { onMessage: (_, socket) => socket.write(encodeFrame(OP.TEXT, Buffer.from('{}'))) }); + await assert.rejects( + () => jsonRpcUnchecked(url, { timeout: 3000 }, s => s.call('x')), + e => e.kind === 'upstream' && /masked/.test(e.message), + ); +}); + +test('a reply that is not JSON ends the session', async t => { + const url = await wsServer(t, { onMessage: (_, socket) => socket.write(serverFrame(OP.TEXT, '')) }); + await assert.rejects( + () => jsonRpcUnchecked(url, { timeout: 3000 }, s => s.call('x')), + e => e.kind === 'upstream', + ); +}); + +test('the service closing mid-call rejects the call', async t => { + const url = await wsServer(t, { onMessage: (_, socket) => socket.destroy() }); + await assert.rejects( + () => jsonRpcUnchecked(url, { timeout: 3000 }, s => s.call('x')), + e => e.kind === 'upstream' || e.code === 'ECONNRESET', + ); +}); + +test('a wait and a call left pending when the session fails reject without an unhandled rejection', async t => { + const unhandled = []; + const onUnhandled = e => unhandled.push(e); + process.on('unhandledRejection', onUnhandled); + t.after(() => process.off('unhandledRejection', onUnhandled)); + const url = await wsServer(t, { + onMessage: (text, socket) => { + if (JSON.parse(text).method === 'fail') echoRpc(text, socket); + }, + }); + await assert.rejects( + () => + jsonRpcUnchecked(url, { timeout: 3000 }, s => { + s.notification('collection_update'); + s.call('never'); + return s.call('fail'); + }), + e => e.rpc?.code === -32001, + ); + await new Promise(r => setTimeout(r, 200)); + assert.deepEqual(unhandled, []); +}); + +test('a service that sends pings but reads no pongs ends the session', async t => { + const url = await wsServer(t, { + onMessage: (_, socket) => { + socket.pause(); + const ping = serverFrame(OP.PING, 'p'.repeat(125)); + socket.write(Buffer.concat(Array(40000).fill(ping))); + }, + }); + await assert.rejects( + () => jsonRpcUnchecked(url, { timeout: 5000 }, s => s.call('x')), + e => e.kind === 'upstream' && /not reading/.test(e.message), + ); +}); + +test('a reserved control frame ends the session', async t => { + const url = await wsServer(t, { onMessage: (_, socket) => socket.write(serverFrame(0x0b, '')) }); + await assert.rejects( + () => jsonRpcUnchecked(url, { timeout: 3000 }, s => s.call('x')), + e => e.kind === 'upstream' && /unknown frame/.test(e.message), + ); +}); + +test('a refused upgrade reports its HTTP status', async t => { + const url = await wsServer(t, { refuse: 404 }); + await assert.rejects( + () => jsonRpcUnchecked(url, { timeout: 3000 }, s => s.call('x')), + e => e.status === 404 && e.kind === 'upstream', + ); +}); + +test('a wrong accept key fails the handshake', async t => { + const url = await wsServer(t, { accept: crypto.randomBytes(20).toString('base64') }); + await assert.rejects( + () => jsonRpcUnchecked(url, { timeout: 3000 }, s => s.call('x')), + e => /handshake/.test(e.message), + ); +}); + +test('a service that never replies times out within the budget', async t => { + const url = await wsServer(t); + const started = Date.now(); + await assert.rejects( + () => jsonRpcUnchecked(url, { timeout: 300 }, s => s.call('x')), + e => e.message === 'Timed out', + ); + assert.ok(Date.now() - started < 2000); +}); + +test('the session is closed after the callback returns', async t => { + let closed = false; + const url = await wsServer(t, { + onMessage: echoRpc, + onControl: (frame, socket) => { + if (frame.opcode === OP.CLOSE) { + closed = true; + socket.end(serverFrame(OP.CLOSE, frame.body)); + } + }, + }); + await jsonRpcUnchecked(url, { timeout: 3000 }, s => s.call('x')); + await new Promise(r => setTimeout(r, 100)); + assert.equal(closed, true); +}); + +test('jsonRpcChecked blocks a private address before connecting', async t => { + let upgraded = false; + const url = await wsServer(t, { onMessage: () => (upgraded = true) }); + await assert.rejects( + () => jsonRpcChecked(url, { timeout: 3000 }, s => s.call('x')), + e => e instanceof SsrfBlockedError && e.status === 403, + ); + assert.equal(upgraded, false); +}); + +test('only http and https addresses are opened', async () => { + await assert.rejects( + () => _internals.openWebSocket('ws://127.0.0.1:1/', {}), + e => e.kind === 'blocked', + ); +}); + +test('the decoder waits for a frame split across chunks', () => { + const seen = []; + const decode = frameDecoder(1 << 20, (fin, opcode, payload) => seen.push([fin, opcode, payload.toString()])); + const frame = serverFrame(OP.TEXT, 'z'.repeat(300)); + decode(frame.subarray(0, 1)); + decode(frame.subarray(1, 3)); + decode(frame.subarray(3, 200)); + assert.equal(seen.length, 0); + decode(Buffer.concat([frame.subarray(200), serverFrame(OP.TEXT, 'b')])); + assert.deepEqual(seen, [ + [true, OP.TEXT, 'z'.repeat(300)], + [true, OP.TEXT, 'b'], + ]); +}); diff --git a/ui/widgets/disk-health/data.js b/ui/widgets/disk-health/data.js index 6c5e913c..170f16f5 100644 --- a/ui/widgets/disk-health/data.js +++ b/ui/widgets/disk-health/data.js @@ -60,25 +60,57 @@ async function scrutinyDeviceOptions(ctx) { return { options }; } -function truenasStatus(ctx, r) { - if (r.status === 401 || r.status === 403) ctx.fail('TrueNAS auth failed, check API key', { kind: ctx.KIND.AUTH }); - if (r.status === 404) ctx.fail('TrueNAS REST API not found (supported on 25.x only, or use Scrutiny)'); +/* Never send the key over http. TrueNAS revokes it. */ +function truenasBase(ctx) { + const raw = String(ctx.config.truenasUrl || '') + .trim() + .replace(/[?#].*$/, ''); + if (!raw) ctx.fail('Enter the TrueNAS URL first.', { kind: ctx.KIND.INVALID }); + if (/^http:\/\//i.test(raw)) + ctx.fail('Use an https:// address. TrueNAS revokes an API key sent over http.', { kind: ctx.KIND.INVALID }); + return ctx.normalizeBase(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`); +} + +function truenasKey(ctx) { + const key = String(ctx.config.truenasKey || '').trim(); + if (!key) ctx.fail('Enter the TrueNAS API key first.', { kind: ctx.KIND.INVALID }); + return key; +} + +function truenasRestStatus(ctx, r) { + if (r.status === 401 || r.status === 403) ctx.fail('TrueNAS refused the API key', { kind: ctx.KIND.AUTH }); + if (r.status === 404) ctx.fail('That address is not TrueNAS'); if (r.status >= 400) ctx.fail('TrueNAS HTTP ' + r.status); } +/* Keep the REST read. TrueNAS before 25.04 has no WebSocket API. */ +async function truenasPools(ctx) { + const base = truenasBase(ctx); + const key = truenasKey(ctx); + const skipTls = ctx.config.truenasSkipTls === true; + let pools; + try { + pools = await ctx.jsonRpc(`${base}/api/current`, { skipTls }, async session => { + if ((await session.call('auth.login_with_api_key', [key])) !== true) + ctx.fail('TrueNAS refused the API key', { kind: ctx.KIND.AUTH }); + return session.call('pool.query'); + }); + } catch (e) { + if (e?.kind !== 'upstream' || typeof e.status !== 'number') throw e; + const r = await ctx.fetchJSON(`${base}/api/v2.0/pool`, { + headers: { Authorization: 'Bearer ' + key }, + skipTls, + timeout: 8000, + }); + truenasRestStatus(ctx, r); + pools = r.data; + } + return (Array.isArray(pools) ? pools : []).filter(p => p && typeof p.name === 'string' && p.name); +} + async function truenasPoolOptions(ctx) { - const { config, fetchJSON, normalizeBase } = ctx; - if (!config.truenasUrl) ctx.fail('Enter the TrueNAS URL first.', { kind: ctx.KIND.INVALID }); - if (!config.truenasKey) ctx.fail('Enter the TrueNAS API key first.', { kind: ctx.KIND.INVALID }); - const r = await fetchJSON(normalizeBase(config.truenasUrl) + '/api/v2.0/pool', { - headers: { Authorization: 'Bearer ' + config.truenasKey }, - timeout: 8000, - }); - truenasStatus(ctx, r); - const options = (Array.isArray(r.data) ? r.data : []) - .filter(p => p && p.name) - .map(p => ({ value: p.name, label: p.name })); - return { options }; + const pools = await truenasPools(ctx); + return { options: pools.map(p => ({ value: p.name, label: p.name })) }; } function diskHealth(ctx) { @@ -132,24 +164,10 @@ function scrutinyFailed(status) { /* A pool's `healthy` flag becomes the per-bay status, in the codes the widget already uses for Scrutiny. */ async function diskHealthTrueNas(ctx) { - const { config, fetchJSON, normalizeBase } = ctx; - const url = config.truenasUrl; - const key = config.truenasKey; - if (!url) ctx.fail('truenasUrl not configured', { kind: ctx.KIND.INVALID }); - if (!key) ctx.fail('TrueNAS API key not configured', { kind: ctx.KIND.INVALID }); + const { config } = ctx; const bays = config.bays || []; - - const base = normalizeBase(url); - const r = await fetchJSON(base + '/api/v2.0/pool', { - headers: { Authorization: 'Bearer ' + key }, - timeout: 8000, - }); - truenasStatus(ctx, r); - const byName = {}; - (Array.isArray(r.data) ? r.data : []).forEach(p => { - if (p && p.name) byName[p.name] = p; - }); + for (const p of await truenasPools(ctx)) byName[p.name] = p; const result = bays.map(name => { if (!name) return null; @@ -166,7 +184,7 @@ async function diskHealthTrueNas(ctx) { }; }); - return { bays: result, href: ctx.serviceLink(config.truenasHref, url), provider: 'truenas' }; + return { bays: result, href: ctx.serviceLink(config.truenasHref, truenasBase(ctx)), provider: 'truenas' }; } /* Unraid's own web server proxies the API at /graphql, and refuses GET unless diff --git a/ui/widgets/disk-health/i18n/de.json b/ui/widgets/disk-health/i18n/de.json index 75b61315..3d2cf84b 100644 --- a/ui/widgets/disk-health/i18n/de.json +++ b/ui/widgets/disk-health/i18n/de.json @@ -15,6 +15,7 @@ "truenasUrl.label": "TrueNAS-URL", "truenasUrl.placeholder": "truenas.local", "truenasKey.label": "TrueNAS-API-Schlüssel", + "truenasSkipTls.label": "Selbstsigniertes Zertifikat zulassen", "truenasHref.label": "Link-URL", "truenasHref.placeholder": "http://truenas.local", "truenasHref.hint": "Die Adresse, unter der dein Browser diesen Dienst öffnet. Leer lassen, um die Adresse oben zu verwenden, sofern dein Browser sie erreicht.", diff --git a/ui/widgets/disk-health/i18n/en.json b/ui/widgets/disk-health/i18n/en.json index 8f9de631..37cd0dbe 100644 --- a/ui/widgets/disk-health/i18n/en.json +++ b/ui/widgets/disk-health/i18n/en.json @@ -15,6 +15,7 @@ "truenasUrl.label": "TrueNAS URL", "truenasUrl.placeholder": "truenas.local", "truenasKey.label": "TrueNAS API Key", + "truenasSkipTls.label": "Allow Self-Signed Certificate", "truenasHref.label": "Link URL", "truenasHref.placeholder": "http://truenas.local", "truenasHref.hint": "The address your browser uses for this service. Leave blank to use the one above, if your browser can reach it.", diff --git a/ui/widgets/disk-health/i18n/es.json b/ui/widgets/disk-health/i18n/es.json index 16b53d55..abfe327f 100644 --- a/ui/widgets/disk-health/i18n/es.json +++ b/ui/widgets/disk-health/i18n/es.json @@ -15,6 +15,7 @@ "truenasUrl.label": "URL de TrueNAS", "truenasUrl.placeholder": "truenas.local", "truenasKey.label": "Clave de API de TrueNAS", + "truenasSkipTls.label": "Permitir certificado autofirmado", "truenasHref.label": "URL del enlace", "truenasHref.placeholder": "http://truenas.local", "truenasHref.hint": "La dirección con la que tu navegador abre este servicio. Déjalo en blanco para usar la de arriba, si tu navegador puede acceder a ella.", diff --git a/ui/widgets/disk-health/i18n/fa.json b/ui/widgets/disk-health/i18n/fa.json index 6a8d8e0c..2f128f64 100644 --- a/ui/widgets/disk-health/i18n/fa.json +++ b/ui/widgets/disk-health/i18n/fa.json @@ -15,6 +15,7 @@ "truenasUrl.label": "آدرس TrueNAS", "truenasUrl.placeholder": "truenas.local", "truenasKey.label": "کلید API TrueNAS", + "truenasSkipTls.label": "پذیرش گواهی خودامضا", "truenasHref.label": "آدرس پیوند", "truenasHref.placeholder": "http://truenas.local", "truenasHref.hint": "آدرسی که مرورگرتان برای این سرویس استفاده می‌کند. اگر مرورگرتان به آدرس بالا دسترسی دارد، خالی بگذارید.", diff --git a/ui/widgets/disk-health/i18n/fr.json b/ui/widgets/disk-health/i18n/fr.json index 0894ff94..d46144df 100644 --- a/ui/widgets/disk-health/i18n/fr.json +++ b/ui/widgets/disk-health/i18n/fr.json @@ -15,6 +15,7 @@ "truenasUrl.label": "URL TrueNAS", "truenasUrl.placeholder": "truenas.local", "truenasKey.label": "Clé d’API TrueNAS", + "truenasSkipTls.label": "Autoriser un certificat auto-signé", "truenasHref.label": "URL du lien", "truenasHref.placeholder": "http://truenas.local", "truenasHref.hint": "L’adresse que votre navigateur utilise pour ce service. Laissez vide pour utiliser celle ci-dessus, si votre navigateur peut l’atteindre.", diff --git a/ui/widgets/disk-health/i18n/zh-Hans.json b/ui/widgets/disk-health/i18n/zh-Hans.json index 801963e1..daa2194a 100644 --- a/ui/widgets/disk-health/i18n/zh-Hans.json +++ b/ui/widgets/disk-health/i18n/zh-Hans.json @@ -15,6 +15,7 @@ "truenasUrl.label": "TrueNAS 地址", "truenasUrl.placeholder": "truenas.local", "truenasKey.label": "TrueNAS API 密钥", + "truenasSkipTls.label": "允许自签名证书", "truenasHref.label": "链接地址", "truenasHref.placeholder": "http://truenas.local", "truenasHref.hint": "浏览器访问此服务所用的地址。留空则使用上方地址(前提是浏览器能访问它)。", diff --git a/ui/widgets/disk-health/widget.json b/ui/widgets/disk-health/widget.json index fd43c99c..92a7ab5b 100644 --- a/ui/widgets/disk-health/widget.json +++ b/ui/widgets/disk-health/widget.json @@ -83,6 +83,16 @@ "equals": "truenas" } }, + { + "key": "truenasSkipTls", + "type": "toggle", + "label": "truenasSkipTls.label", + "default": false, + "showIf": { + "field": "diskProvider", + "equals": "truenas" + } + }, { "key": "truenasHref", "type": "text", diff --git a/ui/widgets/system-summary/data.js b/ui/widgets/system-summary/data.js index 4124bc81..f9517817 100644 --- a/ui/widgets/system-summary/data.js +++ b/ui/widgets/system-summary/data.js @@ -21,6 +21,7 @@ async function systemSummary(ctx) { beszel: systemSummaryBeszel, unraid: systemSummaryUnraid, proxmox: systemSummaryProxmox, + truenas: systemSummaryTrueNas, }, { field: 'statProvider', default: 'system' }, ); @@ -32,6 +33,7 @@ function summaryLink(ctx) { if (c.statProvider === 'glances') return ctx.serviceLink(c.href, c.glancesUrl); if (c.statProvider === 'unraid') return ctx.serviceLink(c.href, c.unraidUrl); if (c.statProvider === 'proxmox') return ctx.serviceLink(c.href, proxmoxAddress(c.proxmoxUrl)); + if (c.statProvider === 'truenas') return ctx.serviceLink(c.href, truenasAddress(c.truenasUrl)); if (c.statProvider !== 'beszel') return c.href || ''; if (!c.beszelSystem) return ctx.serviceLink(c.href, c.beszelUrl); return ctx.serviceLink(c.href, c.beszelUrl, `/system/${encodeURIComponent(c.beszelSystem)}`); @@ -688,6 +690,116 @@ async function systemSummaryProxmox(ctx) { }; } +/* ── TrueNAS ─────────────────────────────────────────────────────────────── */ + +/* Never send the key over http. TrueNAS revokes it. */ +function truenasAddress(raw) { + const s = String(raw || '') + .trim() + .replace(/[?#].*$/, ''); + return !s || /^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `https://${s}`; +} + +function truenasBase(ctx) { + const address = truenasAddress(ctx.config.truenasUrl); + if (!address) ctx.fail('Enter the TrueNAS URL first.', { kind: ctx.KIND.INVALID }); + if (/^http:\/\//i.test(address)) + ctx.fail('Use an https:// address. TrueNAS revokes an API key sent over http.', { kind: ctx.KIND.INVALID }); + return ctx.normalizeBase(address); +} + +const isDenied = e => e?.rpc?.data?.errname === 'EACCES'; + +async function truenasSession(ctx, fn) { + const base = truenasBase(ctx); + const key = String(ctx.config.truenasKey || '').trim(); + if (!key) ctx.fail('Enter the TrueNAS API key first.', { kind: ctx.KIND.INVALID }); + try { + return await ctx.jsonRpc(`${base}/api/current`, { skipTls: ctx.config.truenasSkipTls === true }, async session => { + if ((await session.call('auth.login_with_api_key', [key])) !== true) + ctx.fail('TrueNAS refused the API key', { kind: ctx.KIND.AUTH }); + return fn(session); + }); + } catch (e) { + if (e?.kind === 'upstream' && typeof e.status === 'number') ctx.fail('That address is not TrueNAS 25.04 or later'); + if (isDenied(e)) + ctx.fail('The API key needs the REPORTING_READ, POOL_READ and DATASET_READ roles', { kind: ctx.KIND.AUTH }); + throw e; + } +} + +const REALTIME = 'reporting.realtime'; + +function truenasRealtime(session) { + const next = session.notification('collection_update', p => p?.collection === REALTIME); + return session.call('core.subscribe', [REALTIME]).then(() => next.then(p => p?.fields || {})); +} + +async function truenasPoolUsage(session, names) { + if (!names.length) return {}; + const rows = await session.call('pool.dataset.query', [ + [['id', 'in', names]], + { extra: { retrieve_children: false } }, + ]); + const out = {}; + for (const d of Array.isArray(rows) ? rows : []) { + const used = Number(d?.used?.parsed) || 0; + const total = used + (Number(d?.available?.parsed) || 0); + if (typeof d?.id === 'string') out[d.id] = { usedPct: pct(used, total), totalGb: total / GB }; + } + return out; +} + +const finite = v => (typeof v === 'number' && Number.isFinite(v) ? v : null); + +async function systemSummaryTrueNas(ctx) { + const slots = ctx.config.slots || []; + const net = ctx.config.network || {}; + const wantsUptime = net.enabled === true && net.mode === 'uptime'; + /* The widget looks an empty slot up as '/'. Here that is the first pool. */ + const mounts = new Set(); + for (const s of slots) { + if (s.type !== 'disk') continue; + mounts.add(String(s.primary || '').trim() || '/'); + const second = String(s.secondary || '').trim(); + if (second) mounts.add(second); + } + const wantsFirstPool = mounts.has('/'); + + return truenasSession(ctx, async session => { + const [fields, pools, info] = await Promise.all([ + truenasRealtime(session), + wantsFirstPool ? session.call('pool.query', [[], { select: ['name'] }]) : null, + wantsUptime ? session.call('system.info').catch(e => (isDenied(e) ? null : Promise.reject(e))) : null, + ]); + const first = (Array.isArray(pools) ? pools : []).find(p => typeof p?.name === 'string')?.name || ''; + const poolOf = mount => (mount === '/' ? first : mount); + const usage = await truenasPoolUsage(session, [...new Set([...mounts].map(poolOf).filter(Boolean))]); + const disk = mount => usage[poolOf(mount)] || { usedPct: 0, totalGb: 0 }; + + const cpu = fields.cpu || {}; + const cores = Object.entries(cpu).filter(([k]) => /^cpu\d+$/.test(k)); + const coreTemps = cores.map(([, c]) => finite(c?.temp)).filter(t => t !== null); + const temp = finite(cpu.cpu?.temp) ?? (coreTemps.length ? Math.max(...coreTemps) : null); + const mem = fields.memory || {}; + const total = finite(mem.physical_memory_total); + const available = finite(mem.physical_memory_available); + /* Available memory leaves out the ZFS cache, which TrueNAS frees on demand. */ + const cache = finite(mem.arc_size) ?? 0; + + return { + cpu: finite(cpu.cpu?.usage), + ram: total && available !== null ? pct(Math.max(0, total - available - cache), total) : null, + temp, + temps: temp === null ? {} : { 0: temp }, + disks: [...mounts].map(mount => ({ mount, ...disk(mount) })), + iowait: null, + procs: null, + uptime: finite(info?.uptime_seconds) === null ? null : Math.floor(info.uptime_seconds), + }; + }); +} + /* A counter is not a rate. Sources that report totals are turned into one here, against the previous reading for the same interface. The first reading has nothing to compare with and reports nothing. diff --git a/ui/widgets/system-summary/i18n/de.json b/ui/widgets/system-summary/i18n/de.json index 88e2d6a3..b39659f3 100644 --- a/ui/widgets/system-summary/i18n/de.json +++ b/ui/widgets/system-summary/i18n/de.json @@ -7,6 +7,7 @@ "statProvider.opt.beszel": "Beszel", "statProvider.opt.unraid": "Unraid", "statProvider.opt.proxmox": "Proxmox", + "statProvider.opt.truenas": "TrueNAS", "glancesUrl.label": "Glances-URL", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "Benutzername", @@ -28,6 +29,10 @@ "proxmoxSecret.label": "Geheimer Schlüssel des Tokens", "proxmoxSkipTls.label": "Selbstsigniertes Zertifikat zulassen", "proxmoxNode.label": "Knoten", + "truenasUrl.label": "TrueNAS-URL", + "truenasUrl.placeholder": "truenas.local", + "truenasKey.label": "TrueNAS-API-Schlüssel", + "truenasSkipTls.label": "Selbstsigniertes Zertifikat zulassen", "refresh.label": "Aktualisierung alle", "refresh.opt.5": "5 s", "refresh.opt.10": "10 s", @@ -65,6 +70,11 @@ "slots.storageSecondary.label": "Zweiter Speicher", "slots.storageSecondary.placeholder": "local", "slots.storage.hint": "Leer lassen für das Root-Dateisystem.", + "slots.poolPrimary.label": "Erster Pool", + "slots.poolPrimary.placeholder": "tank", + "slots.poolSecondary.label": "Zweiter Pool", + "slots.poolSecondary.placeholder": "apps", + "slots.pool.hint": "Leer lassen für den ersten Pool.", "slots.thermalZone.label": "Thermalzone", "slots.sensor.label": "Sensor", "slots.sensor.hint": "Oben die URL eintragen, dann die Liste laden.", @@ -72,6 +82,7 @@ "slots.beszelHint": "Beszel meldet keine Prozessanzahl.", "slots.unraidHint": "Unraid meldet weder IO-Wartezeit noch Prozessanzahl.", "slots.proxmoxHint": "Proxmox meldet weder Temperatur noch Prozessanzahl.", + "slots.truenasHint": "TrueNAS meldet weder IO-Wartezeit noch Prozessanzahl.", "network.label": "Netzwerk", "network.enabled.label": "Netzwerkzeile anzeigen", "network.mode.label": "Modus", diff --git a/ui/widgets/system-summary/i18n/en.json b/ui/widgets/system-summary/i18n/en.json index 5b8f3a4b..1c35ea3c 100644 --- a/ui/widgets/system-summary/i18n/en.json +++ b/ui/widgets/system-summary/i18n/en.json @@ -7,6 +7,7 @@ "statProvider.opt.beszel": "Beszel", "statProvider.opt.unraid": "Unraid", "statProvider.opt.proxmox": "Proxmox", + "statProvider.opt.truenas": "TrueNAS", "glancesUrl.label": "Glances URL", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "Username", @@ -28,6 +29,10 @@ "proxmoxSecret.label": "Token Secret", "proxmoxSkipTls.label": "Allow Self-Signed Certificate", "proxmoxNode.label": "Node", + "truenasUrl.label": "TrueNAS URL", + "truenasUrl.placeholder": "truenas.local", + "truenasKey.label": "TrueNAS API Key", + "truenasSkipTls.label": "Allow Self-Signed Certificate", "refresh.label": "Update Every", "refresh.opt.5": "5 s", "refresh.opt.10": "10 s", @@ -65,6 +70,11 @@ "slots.storageSecondary.label": "Second Storage", "slots.storageSecondary.placeholder": "local", "slots.storage.hint": "Leave empty for the root filesystem.", + "slots.poolPrimary.label": "First Pool", + "slots.poolPrimary.placeholder": "tank", + "slots.poolSecondary.label": "Second Pool", + "slots.poolSecondary.placeholder": "apps", + "slots.pool.hint": "Leave empty for the first pool.", "slots.thermalZone.label": "Thermal Zone", "slots.sensor.label": "Sensor", "slots.sensor.hint": "Enter the URL above, then load the list.", @@ -72,6 +82,7 @@ "slots.beszelHint": "Beszel does not report a process count.", "slots.unraidHint": "Unraid does not report IO wait or a process count.", "slots.proxmoxHint": "Proxmox does not report temperature or a process count.", + "slots.truenasHint": "TrueNAS does not report IO wait or a process count.", "network.label": "Network", "network.enabled.label": "Show Network Row", "network.mode.label": "Mode", diff --git a/ui/widgets/system-summary/i18n/es.json b/ui/widgets/system-summary/i18n/es.json index 991d2807..bc80b323 100644 --- a/ui/widgets/system-summary/i18n/es.json +++ b/ui/widgets/system-summary/i18n/es.json @@ -7,6 +7,7 @@ "statProvider.opt.beszel": "Beszel", "statProvider.opt.unraid": "Unraid", "statProvider.opt.proxmox": "Proxmox", + "statProvider.opt.truenas": "TrueNAS", "glancesUrl.label": "URL de Glances", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "Usuario", @@ -28,6 +29,10 @@ "proxmoxSecret.label": "Secreto del token", "proxmoxSkipTls.label": "Permitir certificado autofirmado", "proxmoxNode.label": "Nodo", + "truenasUrl.label": "URL de TrueNAS", + "truenasUrl.placeholder": "truenas.local", + "truenasKey.label": "Clave de API de TrueNAS", + "truenasSkipTls.label": "Permitir certificado autofirmado", "refresh.label": "Actualizar cada", "refresh.opt.5": "5 s", "refresh.opt.10": "10 s", @@ -65,6 +70,11 @@ "slots.storageSecondary.label": "Segundo almacenamiento", "slots.storageSecondary.placeholder": "local", "slots.storage.hint": "Déjalo vacío para el sistema de archivos raíz.", + "slots.poolPrimary.label": "Primer pool", + "slots.poolPrimary.placeholder": "tank", + "slots.poolSecondary.label": "Segundo pool", + "slots.poolSecondary.placeholder": "apps", + "slots.pool.hint": "Déjalo vacío para el primer pool.", "slots.thermalZone.label": "Zona térmica", "slots.sensor.label": "Sensor", "slots.sensor.hint": "Introduce la URL de arriba y carga la lista.", @@ -72,6 +82,7 @@ "slots.beszelHint": "Beszel no informa del número de procesos.", "slots.unraidHint": "Unraid no informa de la espera de E/S ni del número de procesos.", "slots.proxmoxHint": "Proxmox no informa de la temperatura ni del número de procesos.", + "slots.truenasHint": "TrueNAS no informa de la espera de E/S ni del número de procesos.", "network.label": "Red", "network.enabled.label": "Mostrar la fila de red", "network.mode.label": "Modo", diff --git a/ui/widgets/system-summary/i18n/fa.json b/ui/widgets/system-summary/i18n/fa.json index c399dd5a..55df3aec 100644 --- a/ui/widgets/system-summary/i18n/fa.json +++ b/ui/widgets/system-summary/i18n/fa.json @@ -7,6 +7,7 @@ "statProvider.opt.beszel": "Beszel", "statProvider.opt.unraid": "Unraid", "statProvider.opt.proxmox": "Proxmox", + "statProvider.opt.truenas": "TrueNAS", "glancesUrl.label": "آدرس Glances", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "نام کاربری", @@ -28,6 +29,10 @@ "proxmoxSecret.label": "کلید محرمانه توکن", "proxmoxSkipTls.label": "پذیرش گواهی خودامضا", "proxmoxNode.label": "گره", + "truenasUrl.label": "آدرس TrueNAS", + "truenasUrl.placeholder": "truenas.local", + "truenasKey.label": "کلید API TrueNAS", + "truenasSkipTls.label": "پذیرش گواهی خودامضا", "refresh.label": "به‌روزرسانی هر", "refresh.opt.5": "۵ ثانیه", "refresh.opt.10": "۱۰ ثانیه", @@ -65,6 +70,11 @@ "slots.storageSecondary.label": "فضای ذخیره‌سازی دوم", "slots.storageSecondary.placeholder": "local", "slots.storage.hint": "برای فایل‌سیستم ریشه خالی بگذارید.", + "slots.poolPrimary.label": "استخر اول", + "slots.poolPrimary.placeholder": "tank", + "slots.poolSecondary.label": "استخر دوم", + "slots.poolSecondary.placeholder": "apps", + "slots.pool.hint": "برای اولین استخر خالی بگذارید.", "slots.thermalZone.label": "ناحیه حرارتی", "slots.sensor.label": "حسگر", "slots.sensor.hint": "آدرس بالا را وارد کنید، سپس فهرست را بارگذاری کنید.", @@ -72,6 +82,7 @@ "slots.beszelHint": "Beszel تعداد فرایندها را گزارش نمی‌کند.", "slots.unraidHint": "Unraid زمان انتظار ورودی/خروجی و تعداد فرایندها را گزارش نمی‌کند.", "slots.proxmoxHint": "Proxmox دما و تعداد فرایندها را گزارش نمی‌کند.", + "slots.truenasHint": "TrueNAS زمان انتظار ورودی/خروجی و تعداد فرایندها را گزارش نمی‌کند.", "network.label": "شبکه", "network.enabled.label": "نمایش ردیف شبکه", "network.mode.label": "حالت", diff --git a/ui/widgets/system-summary/i18n/fr.json b/ui/widgets/system-summary/i18n/fr.json index 582ca1d7..714bf012 100644 --- a/ui/widgets/system-summary/i18n/fr.json +++ b/ui/widgets/system-summary/i18n/fr.json @@ -7,6 +7,7 @@ "statProvider.opt.beszel": "Beszel", "statProvider.opt.unraid": "Unraid", "statProvider.opt.proxmox": "Proxmox", + "statProvider.opt.truenas": "TrueNAS", "glancesUrl.label": "URL Glances", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "Nom d’utilisateur", @@ -28,6 +29,10 @@ "proxmoxSecret.label": "Secret du jeton", "proxmoxSkipTls.label": "Autoriser un certificat auto-signé", "proxmoxNode.label": "Nœud", + "truenasUrl.label": "URL TrueNAS", + "truenasUrl.placeholder": "truenas.local", + "truenasKey.label": "Clé d’API TrueNAS", + "truenasSkipTls.label": "Autoriser un certificat auto-signé", "refresh.label": "Fréquence d’actualisation", "refresh.opt.5": "5 s", "refresh.opt.10": "10 s", @@ -65,6 +70,11 @@ "slots.storageSecondary.label": "Deuxième stockage", "slots.storageSecondary.placeholder": "local", "slots.storage.hint": "Laissez vide pour le système de fichiers racine.", + "slots.poolPrimary.label": "Premier pool", + "slots.poolPrimary.placeholder": "tank", + "slots.poolSecondary.label": "Deuxième pool", + "slots.poolSecondary.placeholder": "apps", + "slots.pool.hint": "Laissez vide pour le premier pool.", "slots.thermalZone.label": "Zone thermique", "slots.sensor.label": "Capteur", "slots.sensor.hint": "Saisissez l’URL ci-dessus, puis chargez la liste.", @@ -72,6 +82,7 @@ "slots.beszelHint": "Beszel ne fournit pas le nombre de processus.", "slots.unraidHint": "Unraid ne fournit ni l’attente d’E/S ni le nombre de processus.", "slots.proxmoxHint": "Proxmox ne fournit ni la température, ni le nombre de processus.", + "slots.truenasHint": "TrueNAS ne fournit ni l’attente d’E/S ni le nombre de processus.", "network.label": "Réseau", "network.enabled.label": "Afficher la ligne réseau", "network.mode.label": "Mode", diff --git a/ui/widgets/system-summary/i18n/zh-Hans.json b/ui/widgets/system-summary/i18n/zh-Hans.json index 79a7626a..2c8fb8fb 100644 --- a/ui/widgets/system-summary/i18n/zh-Hans.json +++ b/ui/widgets/system-summary/i18n/zh-Hans.json @@ -7,6 +7,7 @@ "statProvider.opt.beszel": "Beszel", "statProvider.opt.unraid": "Unraid", "statProvider.opt.proxmox": "Proxmox", + "statProvider.opt.truenas": "TrueNAS", "glancesUrl.label": "Glances 地址", "glancesUrl.placeholder": "glances:61208", "glancesUser.label": "用户名", @@ -28,6 +29,10 @@ "proxmoxSecret.label": "令牌密钥", "proxmoxSkipTls.label": "允许自签名证书", "proxmoxNode.label": "节点", + "truenasUrl.label": "TrueNAS 地址", + "truenasUrl.placeholder": "truenas.local", + "truenasKey.label": "TrueNAS API 密钥", + "truenasSkipTls.label": "允许自签名证书", "refresh.label": "更新间隔", "refresh.opt.5": "5 秒", "refresh.opt.10": "10 秒", @@ -65,6 +70,11 @@ "slots.storageSecondary.label": "第二个存储", "slots.storageSecondary.placeholder": "local", "slots.storage.hint": "留空表示根文件系统。", + "slots.poolPrimary.label": "第一个存储池", + "slots.poolPrimary.placeholder": "tank", + "slots.poolSecondary.label": "第二个存储池", + "slots.poolSecondary.placeholder": "apps", + "slots.pool.hint": "留空表示第一个存储池。", "slots.thermalZone.label": "温度区域", "slots.sensor.label": "传感器", "slots.sensor.hint": "先填写上方地址,然后加载列表。", @@ -72,6 +82,7 @@ "slots.beszelHint": "Beszel 不报告进程数。", "slots.unraidHint": "Unraid 不报告 IO 等待和进程数。", "slots.proxmoxHint": "Proxmox 不报告温度和进程数。", + "slots.truenasHint": "TrueNAS 不报告 IO 等待和进程数。", "network.label": "网络", "network.enabled.label": "显示网络行", "network.mode.label": "模式", diff --git a/ui/widgets/system-summary/widget.json b/ui/widgets/system-summary/widget.json index ba997ec6..46199404 100644 --- a/ui/widgets/system-summary/widget.json +++ b/ui/widgets/system-summary/widget.json @@ -37,6 +37,10 @@ { "value": "proxmox", "label": "statProvider.opt.proxmox" + }, + { + "value": "truenas", + "label": "statProvider.opt.truenas" } ] }, @@ -179,6 +183,35 @@ "equals": "proxmox" } }, + { + "key": "truenasUrl", + "type": "text", + "label": "truenasUrl.label", + "placeholder": "truenasUrl.placeholder", + "showIf": { + "field": "statProvider", + "equals": "truenas" + } + }, + { + "key": "truenasKey", + "type": "secret", + "label": "truenasKey.label", + "showIf": { + "field": "statProvider", + "equals": "truenas" + } + }, + { + "key": "truenasSkipTls", + "type": "toggle", + "label": "truenasSkipTls.label", + "default": false, + "showIf": { + "field": "statProvider", + "equals": "truenas" + } + }, { "key": "refresh", "type": "select", @@ -626,6 +659,75 @@ }, "hint": "slots.proxmoxHint" }, + { + "key": "slots", + "type": "group", + "label": "slots.label", + "min": 3, + "max": 3, + "fields": [ + { + "key": "type", + "type": "select", + "label": "slots.type.label", + "default": "cpu", + "options": [ + { + "value": "cpu", + "label": "slots.type.opt.cpu" + }, + { + "value": "ram", + "label": "slots.type.opt.ram" + }, + { + "value": "temp", + "label": "slots.type.opt.temp" + }, + { + "value": "disk", + "label": "slots.type.opt.disk" + } + ] + }, + { + "key": "primary", + "type": "text", + "label": "slots.poolPrimary.label", + "placeholder": "slots.poolPrimary.placeholder", + "showIf": { + "field": "type", + "equals": "disk" + }, + "hint": "slots.pool.hint", + "cosmetic": true + }, + { + "key": "secondary", + "type": "text", + "label": "slots.poolSecondary.label", + "placeholder": "slots.poolSecondary.placeholder", + "optional": true, + "showIf": { + "field": "type", + "equals": "disk" + }, + "cosmetic": true + }, + { + "key": "color", + "type": "color", + "label": "slots.color.label", + "default": "#0091FF", + "cosmetic": true + } + ], + "showIf": { + "field": "statProvider", + "equals": "truenas" + }, + "hint": "slots.truenasHint" + }, { "key": "network", "type": "object", @@ -1137,6 +1239,98 @@ "field": "statProvider", "equals": "proxmox" } + }, + { + "key": "network", + "type": "object", + "label": "network.label", + "fields": [ + { + "key": "enabled", + "type": "toggle", + "label": "network.enabled.label", + "default": false, + "cosmetic": true + }, + { + "key": "mode", + "type": "select", + "variant": "pills", + "label": "network.mode.label", + "default": "speed", + "showIf": { + "field": "enabled", + "equals": true + }, + "options": [ + { + "value": "speed", + "label": "network.mode.opt.speed" + }, + { + "value": "uptime", + "label": "network.mode.opt.uptime" + } + ], + "cosmetic": true + }, + { + "key": "provider", + "type": "select", + "variant": "pills", + "label": "network.provider.label", + "default": "myspeed", + "showIf": { + "field": "mode", + "equals": "speed" + }, + "options": [ + { + "value": "myspeed", + "label": "network.provider.opt.myspeed" + }, + { + "value": "speedtest-tracker", + "label": "network.provider.opt.speedtest-tracker" + } + ] + }, + { + "key": "url", + "type": "text", + "label": "network.url.label", + "placeholder": "network.url.placeholder", + "showIf": { + "field": "mode", + "equals": "speed" + } + }, + { + "key": "myspeedPass", + "type": "secret", + "label": "network.myspeedPass.label", + "optional": true, + "showIf": { + "field": "provider", + "equals": "myspeed" + } + }, + { + "key": "stToken", + "type": "secret", + "label": "network.stToken.label", + "hint": "network.stToken.hint", + "optional": true, + "showIf": { + "field": "provider", + "equals": "speedtest-tracker" + } + } + ], + "showIf": { + "field": "statProvider", + "equals": "truenas" + } } ] }