فهرست منبع

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

Jason Fischer 2 سال پیش
والد
کامیت
ac4dcd3222

+ 0 - 1
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",

+ 0 - 101
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'}

+ 27 - 7
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 (
       <div className="flex flex-col justify-center first:ml-0 ml-4">
         <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 (
       <div className="flex flex-col justify-center first:ml-0 ml-4">
         <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 (
     <div className="flex-none flex flex-row items-center mr-3 py-1.5">
       <div className="flex flex-col">

+ 0 - 53
src/pages/api/widgets/unifi.js

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

+ 1 - 1
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);

+ 103 - 0
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);
+}

+ 14 - 0
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;

+ 3 - 0
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;