From 86740c6d7bbdcd012f5cf7cd4da74fbd7cdf4665 Mon Sep 17 00:00:00 2001 From: Dan Geraghty <38405106+DanGRT@users.noreply.github.com> Date: Mon, 29 Jan 2024 20:33:31 +0000 Subject: [PATCH] Feature: OpenWRT service widget (#2782) * Feat: OpenWRT widget implementation * Update proxy.js * fixes from review --------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/widgets/services/openwrt.md | 54 +++++++++ public/locales/en/common.json | 8 ++ src/utils/config/service-helpers.js | 6 + src/widgets/components.js | 1 + src/widgets/openwrt/component.jsx | 9 ++ src/widgets/openwrt/methods/interface.jsx | 37 +++++++ src/widgets/openwrt/methods/system.jsx | 27 +++++ src/widgets/openwrt/proxy.js | 128 ++++++++++++++++++++++ src/widgets/openwrt/widget.js | 8 ++ src/widgets/widgets.js | 2 + 10 files changed, 280 insertions(+) create mode 100644 docs/widgets/services/openwrt.md create mode 100644 src/widgets/openwrt/component.jsx create mode 100644 src/widgets/openwrt/methods/interface.jsx create mode 100644 src/widgets/openwrt/methods/system.jsx create mode 100644 src/widgets/openwrt/proxy.js create mode 100644 src/widgets/openwrt/widget.js diff --git a/docs/widgets/services/openwrt.md b/docs/widgets/services/openwrt.md new file mode 100644 index 000000000..c1c3ee94d --- /dev/null +++ b/docs/widgets/services/openwrt.md @@ -0,0 +1,54 @@ +--- +title: OpenWRT +description: OpenWRT widget configuration +--- + +Learn more about [OpenWRT](https://openwrt.org/). + +Provides information from OpenWRT + +```yaml +widget: + type: openwrt + url: http://host.or.ip + username: homepage + password: pass + interfaceName: eth0 # optional +``` + +## Interface + +Setting `interfaceName` (e.g. eth0) will display information for that particular device, otherwise the widget will display general system info. + +## Authorization + +In order for homepage to access the OpenWRT RPC endpoints you will need to [create an ACL](https://openwrt.org/docs/techref/ubus#acls) and [new user](https://openwrt.org/docs/techref/ubus#authentication) in OpenWRT. + +Create an ACL named `homepage.json` in `/usr/share/rpcd/acl.d/`, the following permissions will suffice: + +``` +{ + "homepage": { + "description": "Homepage widget", + "read": { + "ubus": { + "network.interface.wan": ["status"], + "network.interface.lan": ["status"], + "network.device": ["status"] + "system": ["info"] + } + }, + } +} +``` + +Then add a user that will use that ACL in `/etc/config/rpc`: + +```config login + option username 'homepage' + option password '' + list read homepage + list write '*' +``` + +This username and password will be used in Homepage's services.yaml to grant access. diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 716f9270e..596377d5b 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -788,6 +788,14 @@ "passed": "Passed", "failed": "Failed" }, + "openwrt": { + "uptime": "Uptime", + "cpuLoad": "CPU Load Avg (5m)", + "up": "Up", + "down": "Down", + "bytesTx": "Transmitted", + "bytesRx": "Received" + }, "uptimerobot": { "status": "Status", "uptime": "Uptime", diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index 8d56e2a53..fb6757b6f 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -429,6 +429,9 @@ export function cleanServiceGroups(groups) { // openmediavault method, + // openwrt + interfaceName, + // opnsense, pfsense wan, @@ -531,6 +534,9 @@ export function cleanServiceGroups(groups) { if (type === "openmediavault") { if (method) cleanedService.widget.method = method; } + if (type === "openwrt") { + if (interfaceName) cleanedService.widget.interfaceName = interfaceName; + } if (type === "customapi") { if (mappings) cleanedService.widget.mappings = mappings; if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval; diff --git a/src/widgets/components.js b/src/widgets/components.js index 497d64077..bb9b00fe3 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -71,6 +71,7 @@ const components = { opnsense: dynamic(() => import("./opnsense/component")), overseerr: dynamic(() => import("./overseerr/component")), openmediavault: dynamic(() => import("./openmediavault/component")), + openwrt: dynamic(() => import("./openwrt/component")), paperlessngx: dynamic(() => import("./paperlessngx/component")), pfsense: dynamic(() => import("./pfsense/component")), photoprism: dynamic(() => import("./photoprism/component")), diff --git a/src/widgets/openwrt/component.jsx b/src/widgets/openwrt/component.jsx new file mode 100644 index 000000000..a8dbd5409 --- /dev/null +++ b/src/widgets/openwrt/component.jsx @@ -0,0 +1,9 @@ +import Interface from "./methods/interface"; +import System from "./methods/system"; + +export default function Component({ service }) { + if (service.widget.interfaceName) { + return ; + } + return ; +} diff --git a/src/widgets/openwrt/methods/interface.jsx b/src/widgets/openwrt/methods/interface.jsx new file mode 100644 index 000000000..91366ec95 --- /dev/null +++ b/src/widgets/openwrt/methods/interface.jsx @@ -0,0 +1,37 @@ +import { useTranslation } from "next-i18next"; + +import useWidgetAPI from "utils/proxy/use-widget-api"; +import Container from "components/services/widget/container"; +import Block from "components/services/widget/block"; + +export default function Component({ service }) { + const { t } = useTranslation(); + const { data, error } = useWidgetAPI(service.widget); + + if (error) { + return ; + } + + if (!data) { + return null; + } + + const { up, bytesTx, bytesRx } = data; + + return ( + + {t("openwrt.up")} + ) : ( + {t("openwrt.down")} + ) + } + /> + + + + ); +} diff --git a/src/widgets/openwrt/methods/system.jsx b/src/widgets/openwrt/methods/system.jsx new file mode 100644 index 000000000..7be8aa29b --- /dev/null +++ b/src/widgets/openwrt/methods/system.jsx @@ -0,0 +1,27 @@ +import { useTranslation } from "next-i18next"; + +import useWidgetAPI from "utils/proxy/use-widget-api"; +import Container from "components/services/widget/container"; +import Block from "components/services/widget/block"; + +export default function Component({ service }) { + const { t } = useTranslation(); + const { data, error } = useWidgetAPI(service.widget); + + if (error) { + return ; + } + + if (!data) { + return null; + } + + const { uptime, cpuLoad } = data; + + return ( + + + + + ); +} diff --git a/src/widgets/openwrt/proxy.js b/src/widgets/openwrt/proxy.js new file mode 100644 index 000000000..04c7a5039 --- /dev/null +++ b/src/widgets/openwrt/proxy.js @@ -0,0 +1,128 @@ +import { sendJsonRpcRequest } from "utils/proxy/handlers/jsonrpc"; +import { formatApiCall } from "utils/proxy/api-helpers"; +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import widgets from "widgets/widgets"; + +const PROXY_NAME = "OpenWRTProxyHandler"; +const logger = createLogger(PROXY_NAME); +const LOGIN_PARAMS = ["00000000000000000000000000000000", "session", "login"]; +const RPC_METHOD = "call"; + +let authToken = "00000000000000000000000000000000"; + +const PARAMS = { + system: ["system", "info", {}], + device: ["network.device", "status", {}], +}; + +async function getWidget(req) { + const { group, service } = req.query; + + if (!group || !service) { + logger.debug("Invalid or missing service '%s' or group '%s'", service, group); + return null; + } + + const widget = await getServiceWidget(group, service); + + if (!widget) { + logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group); + return null; + } + + return widget; +} + +function isUnauthorized(data) { + const json = JSON.parse(data.toString()); + return json?.error?.code === -32002; +} + +async function login(url, username, password) { + const response = await sendJsonRpcRequest(url, RPC_METHOD, [...LOGIN_PARAMS, { username, password }]); + + if (response[0] === 200) { + const responseData = JSON.parse(response[2]); + authToken = responseData[1].ubus_rpc_session; + } + + return response; +} + +async function fetchInterface(url, interfaceName) { + const [, contentType, data] = await sendJsonRpcRequest(url, RPC_METHOD, [authToken, ...PARAMS.device]); + if (isUnauthorized(data)) { + return [401, contentType, data]; + } + const response = JSON.parse(data.toString())[1]; + const networkInterface = response[interfaceName]; + if (!networkInterface) { + return [404, contentType, { error: "Interface not found" }]; + } + + const interfaceInfo = { + up: networkInterface.up, + bytesRx: networkInterface.statistics.rx_bytes, + bytesTx: networkInterface.statistics.tx_bytes, + }; + return [200, contentType, interfaceInfo]; +} + +async function fetchSystem(url) { + const [, contentType, data] = await sendJsonRpcRequest(url, RPC_METHOD, [authToken, ...PARAMS.system]); + if (isUnauthorized(data)) { + return [401, contentType, data]; + } + const systemResponse = JSON.parse(data.toString())[1]; + const response = { + uptime: systemResponse.uptime, + cpuLoad: systemResponse.load[1], + }; + return [200, contentType, response]; +} + +async function fetchData(url, widget) { + let response; + if (widget.interfaceName) { + response = await fetchInterface(url, widget.interfaceName); + } else { + response = await fetchSystem(url); + } + return response; +} + +export default async function proxyHandler(req, res) { + const { group, service } = 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 getWidget(req); + + 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" }); + } + + const api = widgets?.[widget.type]?.api; + const url = new URL(formatApiCall(api, { ...widget })); + + let [status, , data] = await fetchData(url, widget); + + if (status === 401) { + const [loginStatus, , loginData] = await login(url, widget.username, widget.password); + if (loginStatus !== 200) { + return res.status(loginStatus).end(loginData); + } + [status, , data] = await fetchData(url, widget); + + if (status === 401) { + return res.status(401).json({ error: "Unauthorized" }); + } + } + + return res.status(200).end(JSON.stringify(data)); +} diff --git a/src/widgets/openwrt/widget.js b/src/widgets/openwrt/widget.js new file mode 100644 index 000000000..e639d3404 --- /dev/null +++ b/src/widgets/openwrt/widget.js @@ -0,0 +1,8 @@ +import proxyHandler from "./proxy"; + +const widget = { + api: "{url}/ubus", + proxyHandler, +}; + +export default widget; diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 553ce626a..fe474406c 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -63,6 +63,7 @@ import opendtu from "./opendtu/widget"; import opnsense from "./opnsense/widget"; import overseerr from "./overseerr/widget"; import openmediavault from "./openmediavault/widget"; +import openwrt from "./openwrt/widget"; import paperlessngx from "./paperlessngx/widget"; import peanut from "./peanut/widget"; import pfsense from "./pfsense/widget"; @@ -171,6 +172,7 @@ const widgets = { opnsense, overseerr, openmediavault, + openwrt, paperlessngx, peanut, pfsense,