Explorar o código

Create reusable Synology proxy
- Migrate DiskStation and DownloadStation to use new proxy
- Move DiskStation proxy UI logic into component

Jason Fischer %!s(int64=2) %!d(string=hai) anos
pai
achega
e62952c2c1

+ 5 - 6
public/locales/en/common.json

@@ -32,6 +32,7 @@
     },
     },
     "resources": {
     "resources": {
         "cpu": "CPU",
         "cpu": "CPU",
+        "mem": "MEM",
         "total": "Total",
         "total": "Total",
         "free": "Free",
         "free": "Free",
         "used": "Used",
         "used": "Used",
@@ -431,10 +432,8 @@
         "job_completion": "Completion"
         "job_completion": "Completion"
     },
     },
     "diskstation": {
     "diskstation": {
-      "uptime": "Uptime",
-      "volumeUsage": "Volume Usage",
-      "volumeTotal": "Total size",
-      "cpuLoad": "CPU Load",
-      "memoryUsage": "Memory Usage"
+        "days": "Days",
+        "uptime": "Uptime",
+        "volumeAvailable": "Available"
     }
     }
-}
+}

+ 176 - 0
src/utils/proxy/handlers/synology.js

@@ -0,0 +1,176 @@
+import cache from "memory-cache";
+
+import getServiceWidget from "utils/config/service-helpers";
+import { asJson, formatApiCall } from "utils/proxy/api-helpers";
+import { httpProxy } from "utils/proxy/http";
+import createLogger from "utils/logger";
+import widgets from "widgets/widgets";
+
+const INFO_ENDPOINT = "{url}/webapi/query.cgi?api=SYNO.API.Info&version=1&method=query";
+const AUTH_ENDPOINT = "{url}/webapi/{path}?api=SYNO.API.Auth&version={maxVersion}&method=login&account={username}&passwd={password}&session=DownloadStation&format=cookie";
+const AUTH_API_NAME = "SYNO.API.Auth";
+
+const proxyName = "synologyProxyHandler";
+const logger = createLogger(proxyName);
+
+async function login(loginUrl) {
+  const [status, contentType, data] = await httpProxy(loginUrl);
+  if (status !== 200) {
+    return [status, contentType, data];
+  }
+
+  const json = asJson(data);
+  if (json?.success !== true) {
+    // from page 16: https://global.download.synology.com/download/Document/Software/DeveloperGuide/Os/DSM/All/enu/DSM_Login_Web_API_Guide_enu.pdf
+    /*
+      Code Description
+      400  No such account or incorrect password
+      401  Account disabled
+      402  Permission denied
+      403  2-step verification code required
+      404  Failed to authenticate 2-step verification code
+    */
+    let message = "Authentication failed.";
+    if (json?.error?.code >= 403) message += " 2FA enabled.";
+    logger.warn("Unable to login.  Code: %d", json?.error?.code);
+    return [401, "application/json", JSON.stringify({ code: json?.error?.code, message })];
+  }
+
+  return [status, contentType, data];
+}
+
+async function getApiInfo(serviceWidget, apiName) {
+  const cacheKey = `${proxyName}__${apiName}`;
+  let { cgiPath, maxVersion } = cache.get(cacheKey) ?? {};
+  if (cgiPath && maxVersion) {
+    return [cgiPath, maxVersion];
+  }
+
+  const infoUrl = formatApiCall(INFO_ENDPOINT, serviceWidget);
+  // eslint-disable-next-line no-unused-vars
+  const [status, contentType, data] = await httpProxy(infoUrl);
+
+  if (status === 200) {
+    try {
+      const json = asJson(data);
+      if (json?.data?.[apiName]) {
+        cgiPath = json.data[apiName].path;
+        maxVersion = json.data[apiName].maxVersion;
+        logger.debug(`Detected ${serviceWidget.type}: apiName '${apiName}', cgiPath '${cgiPath}', and maxVersion ${maxVersion}`);
+        cache.put(cacheKey, { cgiPath, maxVersion });
+        return [cgiPath, maxVersion];
+      }
+    }
+    catch {
+      logger.warn(`Error ${status} obtaining ${apiName} info`);
+    }
+  }
+
+  return [null, null];
+}
+
+async function handleUnsuccessfulResponse(serviceWidget, url) {
+  logger.debug(`Attempting login to ${serviceWidget.type}`);
+
+  // eslint-disable-next-line no-unused-vars
+  const [apiPath, maxVersion] = await getApiInfo(serviceWidget, AUTH_API_NAME);
+
+  const authArgs = { path: apiPath ?? "entry.cgi", maxVersion: maxVersion ?? 7, ...serviceWidget };
+  const loginUrl = formatApiCall(AUTH_ENDPOINT, authArgs);
+  
+  const [status, contentType, data] = await login(loginUrl);
+  if (status !== 200) {
+    return [status, contentType, data];
+  }
+
+  return httpProxy(url);
+}
+
+function toError(url, synologyError) {
+  // commeon codes (100 => 199) from:
+  // https://global.download.synology.com/download/Document/Software/DeveloperGuide/Os/DSM/All/enu/DSM_Login_Web_API_Guide_enu.pdf
+  const code = synologyError.error?.code ?? synologyError.error ?? synologyError.code ?? 100;
+  const error = { code };
+  switch (code) {
+    case 102:
+      error.error = "The requested API does not exist.";
+      break;
+
+    case 103:
+      error.error = "The requested method does not exist.";
+      break;
+
+    case 104:
+      error.error = "The requested version does not support the functionality.";
+      break;
+
+    case 105:
+      error.error = "The logged in session does not have permission.";
+      break;
+
+    case 106:
+      error.error = "Session timeout.";
+      break;
+
+    case 107:
+      error.error = "Session interrupted by duplicated login.";
+      break;
+
+    case 119:
+      error.error = "Invalid session or SID not found.";
+      break;
+
+    default:
+      error.error = synologyError.message ?? "Unknown error.";
+      break;
+  }
+  logger.warn(`Unable to call ${url}.  code: ${code}, error: ${error.error}.`)
+  return error;
+}
+
+export default async function synologyProxyHandler(req, res) {
+  const { group, service, endpoint } = req.query;
+
+  if (!group || !service) {
+    return res.status(400).json({ error: "Invalid proxy service type" });
+  }
+
+  const serviceWidget = await getServiceWidget(group, service);
+  const widget = widgets?.[serviceWidget.type];
+  const mapping = widget?.mappings?.[endpoint];
+  if (!widget.api || !mapping) {
+    return res.status(403).json({ error: "Service does not support API calls" });
+  }
+
+  const [cgiPath, maxVersion] = await getApiInfo(serviceWidget, mapping.apiName);
+  if (!cgiPath || !maxVersion) {
+    return res.status(400).json({ error: `Unrecognized API name: ${mapping.apiName}`})
+  }
+
+  const url = formatApiCall(widget.api, {
+    apiName: mapping.apiName,
+    apiMethod: mapping.apiMethod,
+    cgiPath,
+    maxVersion,
+    ...serviceWidget
+  });
+  let [status, contentType, data] = await httpProxy(url);
+  if (status !== 200) {
+    logger.debug("Error %d calling url %s", status, url);
+    return res.status(status, data);
+  }
+
+  let json = asJson(data);
+  if (json?.success !== true) {
+    logger.debug(`Attempting login to ${serviceWidget.type}`);
+    [status, contentType, data] = await handleUnsuccessfulResponse(serviceWidget, url);
+    json = asJson(data);
+  }
+
+  if (json.success !== true) {
+    data = toError(url, json);
+    status = 500;
+  }
+  if (contentType) res.setHeader("Content-Type", contentType);
+  return res.status(status).send(data);
+}

+ 28 - 15
src/widgets/diskstation/component.jsx

@@ -6,35 +6,48 @@ import useWidgetAPI from "utils/proxy/use-widget-api";
 
 
 export default function Component({ service }) {
 export default function Component({ service }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
-
   const { widget } = service;
   const { widget } = service;
+  const { data: infoData, error: infoError } = useWidgetAPI(widget, "system_info");
+  const { data: storageData, error: storageError } = useWidgetAPI(widget, "system_storage");
+  const { data: utilizationData, error: utilizationError } = useWidgetAPI(widget, "utilization");
 
 
-  const { data: dsData, error: dsError } = useWidgetAPI(widget);
-
-  if (dsError) {
-    return <Container error={ dsError } />;
+  if (storageError || infoError || utilizationError) {
+    return <Container error={ storageError ?? infoError ?? utilizationError } />;
   }
   }
 
 
-  if (!dsData) {
+  if (!storageData || !infoData || !utilizationData) {
     return (
     return (
       <Container service={service}>
       <Container service={service}>
         <Block label="diskstation.uptime" />
         <Block label="diskstation.uptime" />
-        <Block label="diskstation.volumeUsage" />
-        <Block label="diskstation.volumeTotal" />
-        <Block label="diskstation.cpuLoad" />
-        <Block label="diskstation.memoryUsage" />
+        <Block label="diskstation.volumeAvailable" />
+        <Block label="resources.cpu" />
+        <Block label="resources.mem" />
       </Container>
       </Container>
     );
     );
   }
   }
 
 
+  // uptime info
+  // eslint-disable-next-line no-unused-vars
+  const [hour, minutes, seconds] = infoData.data.up_time.split(":");
+  const days = Math.floor(hour / 24);
+  const uptime = `${ t("common.number", { value: days }) } ${ t("diskstation.days") }`;
+
+  // storage info
+  // TODO: figure out how to display info for more than one volume
+  const volume = storageData.data.vol_info?.[0];
+  const freeVolume = 100 - (100 * (parseFloat(volume?.used_size) / parseFloat(volume?.total_size)));
+
+  // utilization info
+  const { cpu, memory } = utilizationData.data;
+  const cpuLoad = parseFloat(cpu.user_load) + parseFloat(cpu.system_load);
+  const memoryUsage = 100 - ((100 * (parseFloat(memory.avail_real) + parseFloat(memory.cached))) / parseFloat(memory.total_real));
 
 
   return (
   return (
     <Container service={service}>
     <Container service={service}>
-      <Block label="diskstation.uptime" value={ dsData.uptime }  />
-      <Block label="diskstation.volumeUsage" value={t("common.percent", { value: dsData.usedVolume })} />
-      <Block label="diskstation.volumeTotal" value={t("common.bytes", { value: dsData.totalSize })} />
-      <Block label="diskstation.cpuLoad" value={t("common.percent", { value: dsData.cpuLoad })} />
-      <Block label="diskstation.memoryUsage" value={t("common.percent", { value: dsData.memoryUsage })} />
+      <Block label="diskstation.uptime" value={ uptime } />
+      <Block label="diskstation.volumeAvailable" value={ t("common.percent", { value: freeVolume }) } />
+      <Block label="resources.cpu" value={ t("common.percent", { value: cpuLoad }) } />
+      <Block label="resources.mem" value={ t("common.percent", { value: memoryUsage }) } />
     </Container>
     </Container>
   );
   );
 }
 }

+ 0 - 119
src/widgets/diskstation/proxy.js

@@ -1,119 +0,0 @@
-
-import { formatApiCall } from "utils/proxy/api-helpers";
-import { httpProxy } from "utils/proxy/http";
-import createLogger from "utils/logger";
-import getServiceWidget from "utils/config/service-helpers";
-
-const proxyName = "synologyProxyHandler";
-
-const logger = createLogger(proxyName);
-
-
-function formatUptime(uptime) {
-  const [hour, minutes, seconds] = uptime.split(":");
-  const days = Math.floor(hour/24);
-  const hours = hour % 24;
-
-  return `${days} d ${hours}h${minutes}m${seconds}s`
-}
-
-async function getApiInfo(api, widget) {
-  const infoAPI = "{url}/webapi/query.cgi?api=SYNO.API.Info&version=1&method=query"
-
-  const infoUrl = formatApiCall(infoAPI, widget);
-  // eslint-disable-next-line no-unused-vars
-  const [status, contentType, data] = await httpProxy(infoUrl);
-
-  if (status === 200) {
-    const json = JSON.parse(data.toString());
-    if (json.data[api]) {
-      const { path, minVersion, maxVersion } = json.data[api];
-      return [ path, minVersion, maxVersion ];
-    }
-  }
-  return [null, null, null];
-}
-
-async function login(widget) {
-  // eslint-disable-next-line no-unused-vars
-  const [path, minVersion, maxVersion] = await getApiInfo("SYNO.API.Auth", widget);
-  const authApi = `{url}/webapi/${path}?api=SYNO.API.Auth&version=${maxVersion}&method=login&account={username}&passwd={password}&format=cookie`
-  const loginUrl = formatApiCall(authApi, widget);
-  const [status, contentType, data] = await httpProxy(loginUrl);
-  if (status !== 200) {
-    return [status, contentType, data];
-  }
-
-  const json = JSON.parse(data.toString());
-
-  if (json?.success !== true) {
-    let message = "Authentication failed.";
-    if (json?.error?.code >= 403) message += " 2FA enabled.";
-    logger.warn("Unable to login.  Code: %d", json?.error?.code);
-    return [401, "application/json", JSON.stringify({ code: json?.error?.code, message })];
-  }
-
-  return [status, contentType, data];
-}
-
-export default async function synologyProxyHandler(req, res) {
-  const { group, service } = req.query;
-
-  if (!group || !service) {
-    return res.status(400).json({ error: "Invalid proxy service type" });
-  }
-
-  const widget = await getServiceWidget(group, service);
-  // eslint-disable-next-line no-unused-vars
-  let [status, contentType, data] = await login(widget);
-  if (status !== 200) {
-    return res.status(status).end(data)
-  }
-  const { sid } = JSON.parse(data.toString()).data;
-  let api = "SYNO.Core.System";
-  // eslint-disable-next-line no-unused-vars
-  let [ path, minVersion, maxVersion] = await getApiInfo(api, widget);
-
-  const storageUrl = `${widget.url}/webapi/${path}?api=${api}&version=${maxVersion}&method=info&type="storage"&_sid=${sid}`;
-
-  [status, contentType, data] = await httpProxy(storageUrl );
-
-  if (status !== 200) {
-    return res.status(status).set("Content-Type", contentType).send(data);
-  }
-  let json=JSON.parse(data.toString());
-  if (json?.success !== true) {
-    return res.status(401).json({ error: "Error getting volume stats" });
-  }
-  const totalSize = parseFloat(json.data.vol_info[0].total_size);
-  const usedVolume = 100 * parseFloat(json.data.vol_info[0].used_size) / parseFloat(json.data.vol_info[0].total_size);
-
-  const healthUrl = `${widget.url}/webapi/${path}?api=${api}&version=${maxVersion}&method=info&_sid=${sid}`;
-  [status, contentType, data] = await httpProxy(healthUrl);
-
-  if (status !== 200) {
-    return res.status(status).set("Content-Type", contentType).send(data);
-  }
-  json=JSON.parse(data.toString());
-  if (json?.success !== true) {
-    return res.status(401).json({ error: "Error getting uptime" });
-  }
-  const uptime = formatUptime(json.data.up_time);
-  api = "SYNO.Core.System.Utilization";
-  // eslint-disable-next-line no-unused-vars
-  [ path, minVersion, maxVersion] = await getApiInfo(api, widget);
-  const sysUrl = `${widget.url}/webapi/${path}?api=${api}&version=${maxVersion}&method=get&_sid=${sid}`;
-  [status, contentType, data] = await httpProxy(sysUrl );
-
-  const memoryUsage = 100 - (100 * (parseFloat(JSON.parse(data.toString()).data.memory.avail_real) + parseFloat(JSON.parse(data.toString()).data.memory.cached)) / parseFloat(JSON.parse(data.toString()).data.memory.total_real));
-  const cpuLoad = parseFloat(JSON.parse(data.toString()).data.cpu.user_load) + parseFloat(JSON.parse(data.toString()).data.cpu.system_load);
-
-  if (contentType) res.setHeader("Content-Type", contentType);
-  return res.status(status).send(JSON.stringify({
-    uptime,
-    usedVolume,
-    totalSize,
-    memoryUsage,
-    cpuLoad,
-  }));
-}

+ 21 - 1
src/widgets/diskstation/widget.js

@@ -1,7 +1,27 @@
-import synologyProxyHandler from "./proxy";
+import synologyProxyHandler from '../../utils/proxy/handlers/synology'
 
 
 const widget = {
 const widget = {
+  // cgiPath and maxVersion are discovered at runtime, don't supply
+  api: "{url}/webapi/{cgiPath}?api={apiName}&version={maxVersion}&method={apiMethod}",
   proxyHandler: synologyProxyHandler,
   proxyHandler: synologyProxyHandler,
+
+  mappings: {
+    "system_storage": {
+      apiName: "SYNO.Core.System",
+      apiMethod: "info&type=\"storage\"",
+      endpoint: "system_storage"
+    },
+    "system_info": {
+      apiName: "SYNO.Core.System",
+      apiMethod: "info",
+      endpoint: "system_info"
+    },
+    "utilization": {
+      apiName: "SYNO.Core.System.Utilization",
+      apiMethod: "get",
+      endpoint: "utilization"
+    }
+  },
 };
 };
 
 
 export default widget;
 export default widget;

+ 0 - 88
src/widgets/downloadstation/proxy.js

@@ -1,88 +0,0 @@
-import { formatApiCall } from "utils/proxy/api-helpers";
-import { httpProxy } from "utils/proxy/http";
-import createLogger from "utils/logger";
-import widgets from "widgets/widgets";
-import getServiceWidget from "utils/config/service-helpers";
-
-const logger = createLogger("downloadstationProxyHandler");
-
-async function login(loginUrl) {
-  const [status, contentType, data] = await httpProxy(loginUrl);
-  if (status !== 200) {
-    return [status, contentType, data];
-  }
-
-  const json = JSON.parse(data.toString());
-  if (json?.success !== true) {
-    // from https://global.download.synology.com/download/Document/Software/DeveloperGuide/Package/DownloadStation/All/enu/Synology_Download_Station_Web_API.pdf
-    /*
-      Code Description
-      400  No such account or incorrect password
-      401  Account disabled
-      402  Permission denied
-      403  2-step verification code required
-      404  Failed to authenticate 2-step verification code
-    */
-    let message = "Authentication failed.";
-    if (json?.error?.code >= 403) message += " 2FA enabled.";
-    logger.warn("Unable to login.  Code: %d", json?.error?.code);
-    return [401, "application/json", JSON.stringify({ code: json?.error?.code, message })];
-  }
-
-  return [status, contentType, data];
-}
-
-export default async function downloadstationProxyHandler(req, res) {
-  const { group, service, endpoint } = req.query;
-
-  if (!group || !service) {
-    return res.status(400).json({ error: "Invalid proxy service type" });
-  }
-
-  const widget = await getServiceWidget(group, service);
-  const api = widgets?.[widget.type]?.api;
-  if (!api) {
-    return res.status(403).json({ error: "Service does not support API calls" });
-  }
-
-  const url = formatApiCall(api, { endpoint, ...widget });
-  let [status, contentType, data] = await httpProxy(url);
-  if (status !== 200) {
-    logger.debug("Error %d calling endpoint %s", status, url);
-    return res.status(status, data);
-  }
-
-  const json = JSON.parse(data.toString());
-  if (json?.success !== true) {
-    logger.debug("Attempting login to DownloadStation");
-
-    const apiInfoUrl = formatApiCall("{url}/webapi/query.cgi?api=SYNO.API.Info&version=1&method=query", widget);
-    let path = "entry.cgi";
-    let maxVersion = 7;
-    [status, contentType, data] = await httpProxy(apiInfoUrl);
-    if (status === 200) {
-      try {
-        const apiAuthInfo = JSON.parse(data.toString()).data['SYNO.API.Auth'];
-        if (apiAuthInfo) {
-          path = apiAuthInfo.path;
-          maxVersion = apiAuthInfo.maxVersion;
-          logger.debug(`Deteceted Downloadstation auth API path: ${path} and maxVersion: ${maxVersion}`);
-        }
-      } catch {
-        logger.debug(`Error ${status} obtaining DownloadStation API info`);
-      }
-    }
-  
-    const authApi = `{url}/webapi/${path}?api=SYNO.API.Auth&version=${maxVersion}&method=login&account={username}&passwd={password}&session=DownloadStation&format=cookie`
-    const loginUrl = formatApiCall(authApi, widget);
-    [status, contentType, data] = await login(loginUrl);
-    if (status !== 200) {
-      return res.status(status).end(data)
-    }
-
-    [status, contentType, data] = await httpProxy(url);
-  }
-
-  if (contentType) res.setHeader("Content-Type", contentType);
-  return res.status(status).send(data);
-}

+ 7 - 4
src/widgets/downloadstation/widget.js

@@ -1,12 +1,15 @@
-import downloadstationProxyHandler from "./proxy";
+import synologyProxyHandler from '../../utils/proxy/handlers/synology'
 
 
 const widget = {
 const widget = {
-  api: "{url}/webapi/DownloadStation/task.cgi?api=SYNO.DownloadStation.Task&version=1&method={endpoint}",
-  proxyHandler: downloadstationProxyHandler,
+  // cgiPath and maxVersion are discovered at runtime, don't supply
+  api: "{url}/webapi/{cgiPath}?api={apiName}&version={maxVersion}&method={apiMethod}",
+  proxyHandler: synologyProxyHandler,
 
 
   mappings: {
   mappings: {
     "list": {
     "list": {
-      endpoint: "list&additional=transfer",
+      apiName: "SYNO.DownloadStation.Task",
+      apiMethod: "list&additional=transfer",
+      endpoint: "list"
     },
     },
   },
   },
 };
 };

+ 4 - 4
src/widgets/proxmox/component.jsx

@@ -24,8 +24,8 @@ export default function Component({ service }) {
       <Container service={service}>
       <Container service={service}>
         <Block label="proxmox.vms" />
         <Block label="proxmox.vms" />
         <Block label="proxmox.lxc" />
         <Block label="proxmox.lxc" />
-        <Block label="proxmox.cpu" />
-        <Block label="proxmox.ram" />
+        <Block label="resources.cpu" />
+        <Block label="resources.ram" />
       </Container>
       </Container>
     );
     );
   }
   }
@@ -46,8 +46,8 @@ export default function Component({ service }) {
     <Container service={service}>
     <Container service={service}>
       <Block label="proxmox.vms" value={`${runningVMs} / ${vms.length}`} />
       <Block label="proxmox.vms" value={`${runningVMs} / ${vms.length}`} />
       <Block label="proxmox.lxc" value={`${runningLXC} / ${lxc.length}`} />
       <Block label="proxmox.lxc" value={`${runningLXC} / ${lxc.length}`} />
-      <Block label="proxmox.cpu" value={t("common.percent", { value: (node.cpu * 100) })} />
-      <Block label="proxmox.mem" value={t("common.percent", { value: ((node.mem / node.maxmem) * 100) })} />
+      <Block label="resources.cpu" value={t("common.percent", { value: (node.cpu * 100) })} />
+      <Block label="resources.mem" value={t("common.percent", { value: ((node.mem / node.maxmem) * 100) })} />
     </Container>
     </Container>
   );
   );
 }
 }