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
pull/341/head
Jason Fischer 2 years ago
parent 952f0295cc
commit ac4dcd3222
No known key found for this signature in database

@ -22,7 +22,6 @@
"next": "^12.3.1", "next": "^12.3.1",
"next-i18next": "^12.0.1", "next-i18next": "^12.0.1",
"node-os-utils": "^1.3.7", "node-os-utils": "^1.3.7",
"node-unifi": "^2.1.0",
"pretty-bytes": "^6.0.0", "pretty-bytes": "^6.0.0",
"raw-body": "^2.5.1", "raw-body": "^2.5.1",
"react": "^18.2.0", "react": "^18.2.0",

@ -24,7 +24,6 @@ specifiers:
next: ^12.3.1 next: ^12.3.1
next-i18next: ^12.0.1 next-i18next: ^12.0.1
node-os-utils: ^1.3.7 node-os-utils: ^1.3.7
node-unifi: ^2.1.0
postcss: ^8.4.16 postcss: ^8.4.16
prettier: ^2.7.1 prettier: ^2.7.1
pretty-bytes: ^6.0.0 pretty-bytes: ^6.0.0
@ -55,7 +54,6 @@ dependencies:
next: 12.3.1_biqbaboplfbrettd7655fr4n2y next: 12.3.1_biqbaboplfbrettd7655fr4n2y
next-i18next: 12.0.1_azq6kxkn3od7qdylwkyksrwopy next-i18next: 12.0.1_azq6kxkn3od7qdylwkyksrwopy
node-os-utils: 1.3.7 node-os-utils: 1.3.7
node-unifi: 2.1.0
pretty-bytes: 6.0.0 pretty-bytes: 6.0.0
raw-body: 2.5.1 raw-body: 2.5.1
react: 18.2.0 react: 18.2.0
@ -460,15 +458,6 @@ packages:
hasBin: true hasBin: true
dev: 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: /ajv/6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
dependencies: dependencies:
@ -588,15 +577,6 @@ packages:
engines: {node: '>=4'} engines: {node: '>=4'}
dev: true 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: /axobject-query/2.2.0:
resolution: {integrity: sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==} resolution: {integrity: sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==}
dev: true dev: true
@ -1414,10 +1394,6 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true dev: true
/eventemitter2/6.4.9:
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
dev: false
/fast-deep-equal/3.1.3: /fast-deep-equal/3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
dev: true dev: true
@ -1512,15 +1488,6 @@ packages:
mime-types: 2.1.35 mime-types: 2.1.35
dev: false 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: /fraction.js/4.2.0:
resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==}
dev: true dev: true
@ -1680,25 +1647,6 @@ packages:
void-elements: 3.1.0 void-elements: 3.1.0
dev: false 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: /http-errors/2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -2142,25 +2090,6 @@ packages:
resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==} resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==}
dev: true 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: /normalize-path/3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2436,20 +2365,10 @@ packages:
once: 1.4.0 once: 1.4.0
dev: false dev: false
/punycode/1.3.2:
resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==}
dev: false
/punycode/2.1.1: /punycode/2.1.1:
resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==} resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==}
engines: {node: '>=6'} 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: /querystringify/2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
dev: false dev: false
@ -3002,13 +2921,6 @@ packages:
requires-port: 1.0.0 requires-port: 1.0.0
dev: false 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: /use-sync-external-store/1.2.0_react@18.2.0:
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies: peerDependencies:
@ -3088,19 +3000,6 @@ packages:
/wrappy/1.0.2: /wrappy/1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} 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: /xtend/4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}

@ -1,17 +1,18 @@
import useSWR from "swr";
import { BiError, BiWifi, BiCheckCircle, BiXCircle } from "react-icons/bi"; import { BiError, BiWifi, BiCheckCircle, BiXCircle } from "react-icons/bi";
import { MdSettingsEthernet } from "react-icons/md"; import { MdSettingsEthernet } from "react-icons/md";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { SiUbiquiti } from "react-icons/si"; import { SiUbiquiti } from "react-icons/si";
import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Widget({ options }) { export default function Widget({ options }) {
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const { data, error } = useSWR( // eslint-disable-next-line no-param-reassign
`/api/widgets/unifi?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}` options.type = "unifi_console";
); const { data: statsData, error: statsError } = useWidgetAPI(options, "stat/sites");
if (error || data?.error) { if (statsError || statsData?.error) {
return ( return (
<div className="flex flex-col justify-center first:ml-0 ml-4"> <div className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end"> <div className="flex flex-row items-center justify-end">
@ -27,7 +28,9 @@ export default function Widget({ options }) {
); );
} }
if (!data) { const defaultSite = statsData?.data?.find(s => s.name === "default");
if (!defaultSite) {
return ( return (
<div className="flex flex-col justify-center first:ml-0 ml-4"> <div className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end"> <div className="flex flex-row items-center justify-end">
@ -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 ( return (
<div className="flex-none flex flex-row items-center mr-3 py-1.5"> <div className="flex-none flex flex-row items-center mr-3 py-1.5">
<div className="flex flex-col"> <div className="flex flex-col">

@ -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}`
})
}
}

@ -2,7 +2,7 @@ export function formatApiCall(url, args) {
const find = /\{.*?\}/g; const find = /\{.*?\}/g;
const replace = (match) => { const replace = (match) => {
const key = match.replace(/\{|\}/g, ""); const key = match.replace(/\{|\}/g, "");
return args[key]; return args[key] || "";
}; };
return url.replace(/\/+$/, "").replace(find, replace); return url.replace(/\/+$/, "").replace(find, replace);

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

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

@ -27,6 +27,7 @@ import strelaysrv from "./strelaysrv/widget";
import tautulli from "./tautulli/widget"; import tautulli from "./tautulli/widget";
import traefik from "./traefik/widget"; import traefik from "./traefik/widget";
import transmission from "./transmission/widget"; import transmission from "./transmission/widget";
import unifi from "./unifi/widget";
const widgets = { const widgets = {
adguard, adguard,
@ -59,6 +60,8 @@ const widgets = {
tautulli, tautulli,
traefik, traefik,
transmission, transmission,
unifi,
unifi_console: unifi
}; };
export default widgets; export default widgets;

Loading…
Cancel
Save