From ac4dcd32222650113aac19d56c4f8261f0264ae8 Mon Sep 17 00:00:00 2001 From: Jason Fischer Date: Fri, 7 Oct 2022 17:12:29 -0700 Subject: [PATCH] Remove node-unifi package dependency - Add custom Unifi proxy built on existing cookie jar and httpProxy - Change formatApiCall to emit empty string instead of undefined on missing key --- package.json | 1 - pnpm-lock.yaml | 101 ----------------- .../widgets/unifi_console/unifi_console.jsx | 34 ++++-- src/pages/api/widgets/unifi.js | 53 --------- src/utils/proxy/api-helpers.js | 2 +- src/widgets/unifi/proxy.js | 103 ++++++++++++++++++ src/widgets/unifi/widget.js | 14 +++ src/widgets/widgets.js | 3 + 8 files changed, 148 insertions(+), 163 deletions(-) delete mode 100644 src/pages/api/widgets/unifi.js create mode 100644 src/widgets/unifi/proxy.js create mode 100644 src/widgets/unifi/widget.js diff --git a/package.json b/package.json index 0ee4cf2b4..3dd2ed0d1 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "next": "^12.3.1", "next-i18next": "^12.0.1", "node-os-utils": "^1.3.7", - "node-unifi": "^2.1.0", "pretty-bytes": "^6.0.0", "raw-body": "^2.5.1", "react": "^18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6cbeb989..49394c8ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,6 @@ specifiers: next: ^12.3.1 next-i18next: ^12.0.1 node-os-utils: ^1.3.7 - node-unifi: ^2.1.0 postcss: ^8.4.16 prettier: ^2.7.1 pretty-bytes: ^6.0.0 @@ -55,7 +54,6 @@ dependencies: next: 12.3.1_biqbaboplfbrettd7655fr4n2y next-i18next: 12.0.1_azq6kxkn3od7qdylwkyksrwopy node-os-utils: 1.3.7 - node-unifi: 2.1.0 pretty-bytes: 6.0.0 raw-body: 2.5.1 react: 18.2.0 @@ -460,15 +458,6 @@ packages: hasBin: true dev: true - /agent-base/6.0.2: - resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} - engines: {node: '>= 6.0.0'} - dependencies: - debug: 4.3.4 - transitivePeerDependencies: - - supports-color - dev: false - /ajv/6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -588,15 +577,6 @@ packages: engines: {node: '>=4'} dev: true - /axios/0.27.2: - resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} - dependencies: - follow-redirects: 1.15.2 - form-data: 4.0.0 - transitivePeerDependencies: - - debug - dev: false - /axobject-query/2.2.0: resolution: {integrity: sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==} dev: true @@ -1414,10 +1394,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /eventemitter2/6.4.9: - resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} - dev: false - /fast-deep-equal/3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true @@ -1512,15 +1488,6 @@ packages: mime-types: 2.1.35 dev: false - /form-data/4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} - engines: {node: '>= 6'} - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - dev: false - /fraction.js/4.2.0: resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} dev: true @@ -1680,25 +1647,6 @@ packages: void-elements: 3.1.0 dev: false - /http-cookie-agent/4.0.2_tough-cookie@4.1.2: - resolution: {integrity: sha512-noTmxdH5CuytTnLj/Qv3Z84e/YFq8yLXAw3pqIYZ25Edhb9pQErIAC+ednw40Cic6Le/h9ryph5/TqsvkOaUCw==} - engines: {node: '>=14.18.0 <15.0.0 || >=16.0.0'} - peerDependencies: - deasync: ^0.1.26 - tough-cookie: ^4.0.0 - undici: ^5.1.1 - peerDependenciesMeta: - deasync: - optional: true - undici: - optional: true - dependencies: - agent-base: 6.0.2 - tough-cookie: 4.1.2 - transitivePeerDependencies: - - supports-color - dev: false - /http-errors/2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -2142,25 +2090,6 @@ packages: resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==} dev: true - /node-unifi/2.1.0: - resolution: {integrity: sha512-vawHGIFEc5XgCXo2I98h72pykVklemI5eE1d50oRZOLpfnYLVDNWF2RfdhvaRSHtVpPjFRshqJP2zuOSWnq4+A==} - engines: {node: '>=14.18.0 <15.0.0 || >=16.0.0'} - dependencies: - axios: 0.27.2 - eventemitter2: 6.4.9 - http-cookie-agent: 4.0.2_tough-cookie@4.1.2 - tough-cookie: 4.1.2 - url: 0.11.0 - ws: 8.9.0 - transitivePeerDependencies: - - bufferutil - - deasync - - debug - - supports-color - - undici - - utf-8-validate - dev: false - /normalize-path/3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2436,20 +2365,10 @@ packages: once: 1.4.0 dev: false - /punycode/1.3.2: - resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} - dev: false - /punycode/2.1.1: resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==} engines: {node: '>=6'} - /querystring/0.2.0: - resolution: {integrity: sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==} - engines: {node: '>=0.4.x'} - deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. - dev: false - /querystringify/2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} dev: false @@ -3002,13 +2921,6 @@ packages: requires-port: 1.0.0 dev: false - /url/0.11.0: - resolution: {integrity: sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==} - dependencies: - punycode: 1.3.2 - querystring: 0.2.0 - dev: false - /use-sync-external-store/1.2.0_react@18.2.0: resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} peerDependencies: @@ -3088,19 +3000,6 @@ packages: /wrappy/1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - /ws/8.9.0: - resolution: {integrity: sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: false - /xtend/4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} diff --git a/src/components/widgets/unifi_console/unifi_console.jsx b/src/components/widgets/unifi_console/unifi_console.jsx index 484982c8c..7427bd23c 100644 --- a/src/components/widgets/unifi_console/unifi_console.jsx +++ b/src/components/widgets/unifi_console/unifi_console.jsx @@ -1,17 +1,18 @@ -import useSWR from "swr"; import { BiError, BiWifi, BiCheckCircle, BiXCircle } from "react-icons/bi"; import { MdSettingsEthernet } from "react-icons/md"; import { useTranslation } from "next-i18next"; import { SiUbiquiti } from "react-icons/si"; +import useWidgetAPI from "utils/proxy/use-widget-api"; + export default function Widget({ options }) { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); - const { data, error } = useSWR( - `/api/widgets/unifi?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}` - ); + // eslint-disable-next-line no-param-reassign + options.type = "unifi_console"; + const { data: statsData, error: statsError } = useWidgetAPI(options, "stat/sites"); - if (error || data?.error) { + if (statsError || statsData?.error) { return (
@@ -27,7 +28,9 @@ export default function Widget({ options }) { ); } - if (!data) { + const defaultSite = statsData?.data?.find(s => s.name === "default"); + + if (!defaultSite) { return (
@@ -42,6 +45,23 @@ export default function Widget({ options }) { ); } + const wan = defaultSite.health.find(h => h.subsystem === "wan"); + const lan = defaultSite.health.find(h => h.subsystem === "lan"); + const wlan = defaultSite.health.find(h => h.subsystem === "wlan"); + const data = { + name: wan.gw_name, + uptime: wan["gw_system-stats"].uptime, + up: wan.status === 'ok', + wlan: { + users: wlan.num_user, + status: wlan.status + }, + lan: { + users: lan.num_user, + status: lan.status + } + }; + return (
diff --git a/src/pages/api/widgets/unifi.js b/src/pages/api/widgets/unifi.js deleted file mode 100644 index f8cbcc1e6..000000000 --- a/src/pages/api/widgets/unifi.js +++ /dev/null @@ -1,53 +0,0 @@ -import { Controller } from "node-unifi"; - -export default async function handler(req, res) { - const { host, port, username, password } = req.query; - - if (!host) { - return res.status(400).json({ error: "Missing host" }); - } - - if (!username) { - return res.status(400).json({ error: "Missing username" }); - } - - if (!password) { - return res.status(400).json({ error: "Missing password" }); - } - - const controller = new Controller({ - host: host, - port: port, - sslverify: false - }); - - try { - //login to the controller - await controller.login(username, password); - - //retrieve sites - const sites = await controller.getSitesStats(); - const default_site = sites.find(s => s.name == "default"); - const wan = default_site.health.find(h => h.subsystem == "wan"); - const lan = default_site.health.find(h => h.subsystem == "lan"); - const wlan = default_site.health.find(h => h.subsystem == "wlan"); - - return res.status(200).json({ - name: wan.gw_name, - uptime: wan['gw_system-stats']['uptime'], - up: wan.status == 'ok', - wlan: { - users: wlan.num_user, - status: wlan.status - }, - lan: { - users: lan.num_user, - status: lan.status - } - }); - } catch (e) { - return res.status(400).json({ - error: `Error communicating with UniFi Console: ${e.message}` - }) - } -} diff --git a/src/utils/proxy/api-helpers.js b/src/utils/proxy/api-helpers.js index 55cd333c5..904c9e967 100644 --- a/src/utils/proxy/api-helpers.js +++ b/src/utils/proxy/api-helpers.js @@ -2,7 +2,7 @@ export function formatApiCall(url, args) { const find = /\{.*?\}/g; const replace = (match) => { const key = match.replace(/\{|\}/g, ""); - return args[key]; + return args[key] || ""; }; return url.replace(/\/+$/, "").replace(find, replace); diff --git a/src/widgets/unifi/proxy.js b/src/widgets/unifi/proxy.js new file mode 100644 index 000000000..d4d50e0d6 --- /dev/null +++ b/src/widgets/unifi/proxy.js @@ -0,0 +1,103 @@ +import { formatApiCall } from "utils/proxy/api-helpers"; +import { httpProxy } from "utils/proxy/http"; +import { addCookieToJar, setCookieHeader } from "utils/proxy/cookie-jar"; +import { getSettings } from "utils/config/config"; +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import widgets from "widgets/widgets"; + +const logger = createLogger("unifiProxyHandler"); + +async function getWidget(req) { + const { group, service, type } = req.query; + + let widget = null; + if (type === 'unifi_console') { + const settings = getSettings(); + widget = settings.unifi_console; + if (!widget) { + logger.debug("There is no unifi_console section in settings.yaml"); + return null; + } + widget.type = "unifi"; + } else { + if (!group || !service) { + logger.debug("Invalid or missing service '%s' or group '%s'", service, group); + return null; + } + + 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; +} + +async function login(widget) { + logger.debug("Unifi isn't logged in or is rejecting the reqeust, logging in."); + + const loginBody = { username: widget.username, password: widget.password, remember: true }; + let loginUrl = `${widget.url}/api`; + if (widget.version === "udm-pro") { + loginUrl += "/auth" + } + loginUrl += "/login"; + + const loginParams = { method: "POST", body: JSON.stringify(loginBody) }; + const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, loginParams); + return [status, contentType, data, responseHeaders]; +} + +export default async function unifiProxyHandler(req, res) { + const widget = await getWidget(req); + if (!widget) { + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const api = widgets?.[widget.type]?.api; + if (!api) { + return res.status(403).json({ error: "Service does not support API calls" }); + } + + widget.prefx = ""; + if (widget.version === "udm-pro") { + widget.prefix = "/proxy/network" + } + + const { endpoint } = req.query; + const url = new URL(formatApiCall(api, { endpoint, ...widget })); + const params = { method: "GET", headers: {} }; + setCookieHeader(url, params); + + let [status, contentType, data, responseHeaders] = await httpProxy(url, params); + if (status === 401) { + [status, contentType, data, responseHeaders] = await login(widget); + + if (status !== 200) { + logger.error("HTTP %d logging in to Unifi. Data: %s", status, data); + return res.status(status).end(data); + } + + const json = JSON.parse(data.toString()); + if (json?.meta?.rc !== "ok") { + logger.error("Error logging in to Unifi: Data: %s", data); + return res.status(401).end(data); + } + + addCookieToJar(url, responseHeaders); + setCookieHeader(url, params); + } + + [status, contentType, data] = await httpProxy(url, params); + + if (status !== 200) { + logger.error("HTTP %d getting data from Unifi. Data: %s", status, data); + } + + if (contentType) res.setHeader("Content-Type", contentType); + return res.status(status).send(data); +} diff --git a/src/widgets/unifi/widget.js b/src/widgets/unifi/widget.js new file mode 100644 index 000000000..928ebd766 --- /dev/null +++ b/src/widgets/unifi/widget.js @@ -0,0 +1,14 @@ +import unifiProxyHandler from "./proxy"; + +const widget = { + api: "{url}{prefix}/api/{endpoint}", + proxyHandler: unifiProxyHandler, + + mappings: { + "stat/sites": { + endpoint: "stat/sites", + }, + } +}; + +export default widget; diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 04665c782..a4cab76b0 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -27,6 +27,7 @@ import strelaysrv from "./strelaysrv/widget"; import tautulli from "./tautulli/widget"; import traefik from "./traefik/widget"; import transmission from "./transmission/widget"; +import unifi from "./unifi/widget"; const widgets = { adguard, @@ -59,6 +60,8 @@ const widgets = { tautulli, traefik, transmission, + unifi, + unifi_console: unifi }; export default widgets;