From 660a5dc0ffbe425c636025fa09e28f97414338fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6ran=20Sander?= Date: Sat, 11 May 2024 20:35:19 +0000 Subject: [PATCH] feat(qs-license): Call webhook when Qlik Sense server license is about to expire Implements #1136 --- src/config/config-gen-api-docs.yaml | 21 ++ src/config/production_template.yaml | 43 +++- src/lib/assert/assert_config_file.js | 228 +++++++++++++++++++- src/lib/qliksense_license.js | 48 ++++- src/lib/webhook_notification.js | 312 +++++++++++++++++++++++++++ 5 files changed, 644 insertions(+), 8 deletions(-) diff --git a/src/config/config-gen-api-docs.yaml b/src/config/config-gen-api-docs.yaml index fc6896a5..c382b3a9 100644 --- a/src/config/config-gen-api-docs.yaml +++ b/src/config/config-gen-api-docs.yaml @@ -162,6 +162,19 @@ Butler: static: # Static attributes/tags to attach to the data sent to InflixDB # - name: foo # value: bar + mqtt: + enable: true + sendRecurring: # Send license data to the MQTT broker at the frequency specified above + enable: true + sendAlert: # Send an MQTT alert if the number of days left on the license is below the threshold + enable: true + webhook: + enable: true + sendRecurring: # Send license data to webhook(s) at the frequency specified above + enable: true + sendAlert: # Send alert to webhook(s) if the number of days left on the license is below + # the threshold or the license has already expired + enable: true licenseMonitor: enable: false frequency: every 6 hours # https://bunkat.github.io/later/parsers.html#text @@ -616,6 +629,12 @@ Butler: serviceMonitor: rateLimit: 5 # Min seconds between outgoing webhook calls, per Windows service that is monitored. Defaults to 5 minutes. webhooks: + qlikSenseServerLicenseMonitor: # Outgoing webhook that Butler will call with info on Qlik Sense server license status + rateLimit: 300 # Min seconds between outgoing webhook calls, per Windows service that is monitored. Defaults to 5 minutes. + webhooks: + qlikSenseServerLicenseExpiryAlert: # Outgoing webhook that Butler will call when Qlik Sense server license is about to expire + rateLimit: 300 # Min seconds between outgoing webhook calls, per Windows service that is monitored. Defaults to 5 minutes. + webhooks: # Scheduler for Qlik Sense tasks scheduler: @@ -648,6 +667,8 @@ Butler: serviceRunningTopic: qliksense/service_running serviceStoppedTopic: qliksense/service_stopped serviceStatusTopic: qliksense/service_status + qlikSenseServerLicenseTopic: qliksense/butler/qliksense_server_license # Topic to which Sense server license info is published + qlikSenseServerLicenseExpireTopic: qliksense/butler/qliksense_server_license_expire # Topic to which Sense server license expiration alerts are published udpServerConfig: enable: false diff --git a/src/config/production_template.yaml b/src/config/production_template.yaml index c9249b7f..1352bbd1 100644 --- a/src/config/production_template.yaml +++ b/src/config/production_template.yaml @@ -141,13 +141,13 @@ Butler: qlikSenseVersion: versionMonitor: enable: true # Should Qlik Sense version info be retrieved? - frequency: every 1 hour # https://bunkat.github.io/later/parsers.html#text + frequency: every 24 hours # https://bunkat.github.io/later/parsers.html#text host: rejectUnauthorized: false # Set to false to ignore warnings/errors caused by Qlik Sense's self-signed certificates. destination: influxDb: # Store version data in InfluxDB. # If enabled, version info will be stored as measurements in InfluxDB. - enable: true + enable: false tag: static: # Static attributes/tags to attach to the data sent to InflixDB - name: foo @@ -156,7 +156,7 @@ Butler: # Settings for monitoring Qlik Sense licenses qlikSenseLicense: serverLicenseMonitor: - enable: false + enable: true frequency: every 24 hours # https://bunkat.github.io/later/parsers.html#text alert: # Alert if the number of days left on the license is below the threshold # License expiry alerts on a global level are enabled here, then configured on @@ -169,6 +169,19 @@ Butler: static: # Static attributes/tags to attach to the data sent to InflixDB - name: foo value: bar + mqtt: + enable: false + sendRecurring: # Send license data to the MQTT broker at the frequency specified above + enable: true + sendAlert: # Send an MQTT alert if the number of days left on the license is below the threshold + enable: true + webhook: + enable: false + sendRecurring: # Send license data to webhook(s) at the frequency specified above + enable: true + sendAlert: # Send alert to webhook(s) if the number of days left on the license is below + # the threshold or the license has already expired + enable: true licenseMonitor: # Monitor Qlik Sense accesds license usage enable: false frequency: every 6 hours # https://bunkat.github.io/later/parsers.html#text @@ -182,7 +195,7 @@ Butler: licenseRelease: # Release unused Qlik Sense access licenses enable: false # true/false. If true, Butler will release unused licenses according to settings below dryRun: true # true/false. If true, Butler will not actually release any licenses, just log what it would have done. - frequency: every 6 hours # https://bunkat.github.io/later/parsers.html#text + frequency: every 24 hours # https://bunkat.github.io/later/parsers.html#text neverRelease: # Various ways of defining which users should never have their licenses released user: # Users who should never have their licenses released - userDir: 'INTERNAL' @@ -662,6 +675,26 @@ Butler: enable: true # Set to true to use a custom CA certificate when calling the webhookURL rejectUnauthorized: true # Set to false to ignore warnings/errors caused by self-signed certificates used on the webhooks server. certCA: /path/to/ca-certificate.pem # Path to the CA certificate file + qlikSenseServerLicenseMonitor: # Outgoing webhook that Butler will call with info on Qlik Sense server license status + rateLimit: 300 # Min seconds between outgoing webhook calls, per Windows service that is monitored. Defaults to 5 minutes. + webhooks: + - description: 'This outgoing webhook makes a PUT and is used to ...' + webhookURL: http://host.my.domain:port/some/path + httpMethod: PUT # GET/POST/PUT. Note that the body and URL query parameters differs depending on which method is used + cert: + enable: false # Set to true to use a custom CA certificate when calling the webhookURL + rejectUnauthorized: true # Set to false to ignore warnings/errors caused by self-signed certificates used on the webhooks server. + certCA: foo + qlikSenseServerLicenseExpiryAlert: # Outgoing webhook that Butler will call when Qlik Sense server license is about to expire + rateLimit: 300 # Min seconds between outgoing webhook calls, per Windows service that is monitored. Defaults to 5 minutes. + webhooks: + - description: 'This outgoing webhook makes a POST and is used to ...' + webhookURL: https://host.my.domain:port/some/path + httpMethod: POST # GET/POST/PUT. Note that the body and URL query parameters differs depending on which method is used + cert: + enable: true # Set to true to use a custom CA certificate when calling the webhookURL + rejectUnauthorized: true # Set to false to ignore warnings/errors caused by self-signed certificates used on the webhooks server. + certCA: /path/to/ca-certificate.pem # Path to the CA certificate file # Scheduler for Qlik Sense tasks scheduler: @@ -694,6 +727,8 @@ Butler: serviceRunningTopic: qliksense/service_running serviceStoppedTopic: qliksense/service_stopped serviceStatusTopic: qliksense/service_status + qlikSenseServerLicenseTopic: qliksense/qliksense_server_license # Topic to which Sense server license info is published + qlikSenseServerLicenseExpireTopic: qliksense/qliksense_server_license_expire # Topic to which Sense server license expiration alerts are published udpServerConfig: enable: false # Should the UDP server responsible for receving task failure/aborted events be started? diff --git a/src/lib/assert/assert_config_file.js b/src/lib/assert/assert_config_file.js index 05bad8b4..7e7ffa08 100644 --- a/src/lib/assert/assert_config_file.js +++ b/src/lib/assert/assert_config_file.js @@ -1107,7 +1107,45 @@ export const configFileStructureAssert = async (config, logger) => { } } else { logger.error( - 'ASSERT CONFIG: Missing config file entry "Butler.qlikSenseLicense.serverLicenseMonitor.destination.influxDb.enabled"' + 'ASSERT CONFIG: Missing config file entry "Butler.qlikSenseLicense.serverLicenseMonitor.destination.influxDb.tag.static"' + ); + configFileCorrect = false; + } + + if (!config.has('Butler.qlikSenseLicense.serverLicenseMonitor.destination.mqtt.enable')) { + logger.error('ASSERT CONFIG: Missing config file entry "Butler.qlikSenseLicense.serverLicenseMonitor.destination.mqtt.enabled"'); + configFileCorrect = false; + } + + if (!config.has('Butler.qlikSenseLicense.serverLicenseMonitor.destination.mqtt.sendRecurring.enable')) { + logger.error( + 'ASSERT CONFIG: Missing config file entry "Butler.qlikSenseLicense.serverLicenseMonitor.destination.mqtt.sendRecurring.enable"' + ); + configFileCorrect = false; + } + + if (!config.has('Butler.qlikSenseLicense.serverLicenseMonitor.destination.mqtt.sendAlert.enable')) { + logger.error( + 'ASSERT CONFIG: Missing config file entry "Butler.qlikSenseLicense.serverLicenseMonitor.destination.mqtt.sendAlert.enable"' + ); + configFileCorrect = false; + } + + if (!config.has('Butler.qlikSenseLicense.serverLicenseMonitor.destination.webhook.enable')) { + logger.error('ASSERT CONFIG: Missing config file entry "Butler.qlikSenseLicense.serverLicenseMonitor.destination.webhook.enabled"'); + configFileCorrect = false; + } + + if (!config.has('Butler.qlikSenseLicense.serverLicenseMonitor.destination.webhook.sendRecurring.enable')) { + logger.error( + 'ASSERT CONFIG: Missing config file entry "Butler.qlikSenseLicense.serverLicenseMonitor.destination.webhook.sendRecurring.enable"' + ); + configFileCorrect = false; + } + + if (!config.has('Butler.qlikSenseLicense.serverLicenseMonitor.destination.webhook.sendAlert.enable')) { + logger.error( + 'ASSERT CONFIG: Missing config file entry "Butler.qlikSenseLicense.serverLicenseMonitor.destination.webhook.sendAlert.enable"' ); configFileCorrect = false; } @@ -3556,6 +3594,7 @@ export const configFileStructureAssert = async (config, logger) => { configFileCorrect = false; } + // Butler.webhookNotification.reloadTaskFailure if (!config.has('Butler.webhookNotification.reloadTaskFailure.rateLimit')) { logger.error('ASSERT CONFIG: Missing config file entry "Butler.webhookNotification.reloadTaskFailure.rateLimit"'); configFileCorrect = false; @@ -3829,6 +3868,181 @@ export const configFileStructureAssert = async (config, logger) => { configFileCorrect = false; } + // Butler.webhookNotification.qlikSenseServerLicenseMonitor + if (!config.has('Butler.webhookNotification.qlikSenseServerLicenseMonitor.rateLimit')) { + logger.error('ASSERT CONFIG: Missing config file entry "Butler.webhookNotification.qlikSenseServerLicenseMonitor.rateLimit"'); + configFileCorrect = false; + } + + // Make sure all entries in Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks are objects with the following properties: + // { + // "description": "A description of the webhook", + // "webhookURL": "https://webhook.site/...", + // "httpMethod": "POST", + // "cert": { + // "enable": true, + // "rejectUnauthorized": true, + // "certCA": "/path/to/ca-cert.pem", + // }, + // } + if (config.has('Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks')) { + const webhooks = config.get('Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks'); + + if (webhooks) { + if (!Array.isArray(webhooks)) { + logger.error('ASSERT CONFIG: "Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks" must be an array'); + configFileCorrect = false; + } else { + webhooks.forEach((webhook, index) => { + if (typeof webhook !== 'object') { + logger.error(`ASSERT CONFIG: "Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks[${index}]" must be an object`); + configFileCorrect = false; + } else { + if (!Object.prototype.hasOwnProperty.call(webhook, 'description')) { + logger.error( + `ASSERT CONFIG: Missing property "description" in "Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks[${index}]"` + ); + configFileCorrect = false; + } + if (!Object.prototype.hasOwnProperty.call(webhook, 'webhookURL')) { + logger.error( + `ASSERT CONFIG: Missing property "webhookURL" in "Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks[${index}]"` + ); + configFileCorrect = false; + } + if (!Object.prototype.hasOwnProperty.call(webhook, 'httpMethod')) { + logger.error( + `ASSERT CONFIG: Missing property "httpMethod" in "Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks[${index}]"` + ); + configFileCorrect = false; + } + if (!Object.prototype.hasOwnProperty.call(webhook, 'cert')) { + logger.error( + `ASSERT CONFIG: Missing property "cert" in "Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks[${index}]"` + ); + configFileCorrect = false; + } else if (typeof webhook.cert !== 'object') { + logger.error( + `ASSERT CONFIG: "Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks[${index}].cert" must be an object` + ); + configFileCorrect = false; + } else { + if (!Object.prototype.hasOwnProperty.call(webhook.cert, 'enable')) { + logger.error( + `ASSERT CONFIG: Missing property "enable" in "Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks[${index}].cert"` + ); + configFileCorrect = false; + } + if (!Object.prototype.hasOwnProperty.call(webhook.cert, 'rejectUnauthorized')) { + logger.error( + `ASSERT CONFIG: Missing property "rejectUnauthorized" in "Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks[${index}].cert"` + ); + configFileCorrect = false; + } + if (!Object.prototype.hasOwnProperty.call(webhook.cert, 'certCA')) { + logger.error( + `ASSERT CONFIG: Missing property "certCA" in "Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks[${index}].cert"` + ); + configFileCorrect = false; + } + } + } + }); + } + } + } else { + logger.error('ASSERT CONFIG: Missing config file entry "Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks"'); + configFileCorrect = false; + } + + // Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert + if (!config.has('Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.rateLimit')) { + logger.error('ASSERT CONFIG: Missing config file entry "Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.rateLimit"'); + configFileCorrect = false; + } + + // Make sure all entries in Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks are objects with the following properties: + // { + // "description": "A description of the webhook", + // "webhookURL": "https://webhook.site/...", + // "httpMethod": "POST", + // "cert": { + // "enable": true, + // "rejectUnauthorized": true, + // "certCA": "/path/to/ca-cert.pem", + // }, + // } + if (config.has('Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks')) { + const webhooks = config.get('Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks'); + + if (webhooks) { + if (!Array.isArray(webhooks)) { + logger.error('ASSERT CONFIG: "Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks" must be an array'); + configFileCorrect = false; + } else { + webhooks.forEach((webhook, index) => { + if (typeof webhook !== 'object') { + logger.error(`ASSERT CONFIG: "Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks[${index}]" must be an object`); + configFileCorrect = false; + } else { + if (!Object.prototype.hasOwnProperty.call(webhook, 'description')) { + logger.error( + `ASSERT CONFIG: Missing property "description" in "Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks[${index}]"` + ); + configFileCorrect = false; + } + if (!Object.prototype.hasOwnProperty.call(webhook, 'webhookURL')) { + logger.error( + `ASSERT CONFIG: Missing property "webhookURL" in "Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks[${index}]"` + ); + configFileCorrect = false; + } + if (!Object.prototype.hasOwnProperty.call(webhook, 'httpMethod')) { + logger.error( + `ASSERT CONFIG: Missing property "httpMethod" in "Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks[${index}]"` + ); + configFileCorrect = false; + } + if (!Object.prototype.hasOwnProperty.call(webhook, 'cert')) { + logger.error( + `ASSERT CONFIG: Missing property "cert" in "Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks[${index}]"` + ); + configFileCorrect = false; + } else if (typeof webhook.cert !== 'object') { + logger.error( + `ASSERT CONFIG: "Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks[${index}].cert" must be an object` + ); + configFileCorrect = false; + } else { + if (!Object.prototype.hasOwnProperty.call(webhook.cert, 'enable')) { + logger.error( + `ASSERT CONFIG: Missing property "enable" in "Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks[${index}].cert"` + ); + configFileCorrect = false; + } + if (!Object.prototype.hasOwnProperty.call(webhook.cert, 'rejectUnauthorized')) { + logger.error( + `ASSERT CONFIG: Missing property "rejectUnauthorized" in "Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks[${index}].cert"` + ); + configFileCorrect = false; + } + if (!Object.prototype.hasOwnProperty.call(webhook.cert, 'certCA')) { + logger.error( + `ASSERT CONFIG: Missing property "certCA" in "Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks[${index}].cert"` + ); + configFileCorrect = false; + } + } + } + }); + } + } + } else { + logger.error('ASSERT CONFIG: Missing config file entry "Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks"'); + configFileCorrect = false; + } + + // Butler.scheduler if (!config.has('Butler.scheduler.enable')) { logger.error('ASSERT CONFIG: Missing config file entry "Butler.scheduler.enable"'); configFileCorrect = false; @@ -3839,6 +4053,7 @@ export const configFileStructureAssert = async (config, logger) => { configFileCorrect = false; } + // Butler.mqttConfig if (!config.has('Butler.mqttConfig.enable')) { logger.error('ASSERT CONFIG: Missing config file entry "Butler.mqttConfig.enable"'); configFileCorrect = false; @@ -3929,6 +4144,17 @@ export const configFileStructureAssert = async (config, logger) => { configFileCorrect = false; } + if (!config.has('Butler.mqttConfig.qlikSenseServerLicenseTopic')) { + logger.error('ASSERT CONFIG: Missing config file entry "Butler.mqttConfig.qlikSenseServerLicenseTopic"'); + configFileCorrect = false; + } + + if (!config.has('Butler.mqttConfig.qlikSenseServerLicenseExpireTopic')) { + logger.error('ASSERT CONFIG: Missing config file entry "Butler.mqttConfig.qlikSenseServerLicenseExpireTopic"'); + configFileCorrect = false; + } + + // Butler.udpServerConfig if (!config.has('Butler.udpServerConfig.enable')) { logger.error('ASSERT CONFIG: Missing config file entry "Butler.udpServerConfig.enable"'); configFileCorrect = false; diff --git a/src/lib/qliksense_license.js b/src/lib/qliksense_license.js index 5760effa..b879faa3 100644 --- a/src/lib/qliksense_license.js +++ b/src/lib/qliksense_license.js @@ -7,6 +7,7 @@ import { postQlikSenseLicenseReleasedToInfluxDB, postQlikSenseServerLicenseStatusToInfluxDB, } from './post_to_influxdb.js'; +import { callQlikSenseServerLicenseWebhook } from './webhook_notification.js'; // Function to check Qlik Sense server license status async function checkQlikSenseServerLicenseStatus(config, logger) { @@ -131,7 +132,12 @@ async function checkQlikSenseServerLicenseStatus(config, logger) { } // Check if we should send data to MQTT - if (config.has('Butler.mqttConfig.enable') && config.get('Butler.mqttConfig.enable') === true) { + if ( + config.has('Butler.mqttConfig.enable') && + config.get('Butler.mqttConfig.enable') === true && + config.has('Butler.qlikSenseLicense.serverLicenseMonitor.destination.mqtt.enable') && + config.get('Butler.qlikSenseLicense.serverLicenseMonitor.destination.mqtt.enable') === true + ) { // Prepare general license payload for MQTT const mqttPayload = { licenseExpired, @@ -154,6 +160,42 @@ async function checkQlikSenseServerLicenseStatus(config, logger) { globals.mqttClient.publish(config.get('Butler.mqttConfig.qlikSenseServerLicenseExpireTopic'), mqttAlertPayload); } } + + // Check if we should send data to webhooks + if ( + config.get('Butler.webhookNotification.enable') === true && + config.get('Butler.qlikSenseLicense.serverLicenseMonitor.destination.webhook.enable') === true + ) { + // Prepare general license payload for webhooks + const webhookPayload = { + licenseExpired, + expiryDateStr, + daysUntilExpiry, + }; + + // Send recurring webhook notification? + if ( + config.get('Butler.webhookNotification.enable') === true && + config.get('Butler.qlikSenseLicense.serverLicenseMonitor.destination.webhook.sendRecurring.enable') === true + ) { + webhookPayload.event = 'server license status'; + callQlikSenseServerLicenseWebhook(webhookPayload); + } + + // Send alert webhook notification? + if ( + config.get('Butler.webhookNotification.enable') === true && + config.get('Butler.qlikSenseLicense.serverLicenseMonitor.destination.webhook.sendAlert.enable') === true + ) { + if (licenseExpired === true) { + webhookPayload.event = 'server license has expired alert'; + callQlikSenseServerLicenseWebhook(webhookPayload); + } else if (daysUntilExpiry <= config.get('Butler.qlikSenseLicense.serverLicenseMonitor.alert.thresholdDays')) { + webhookPayload.event = 'server license about to expire alert'; + callQlikSenseServerLicenseWebhook(webhookPayload); + } + } + } } catch (err) { logger.error(`QLIKSENSE SERVER LICENSE MONITOR: ${err}`); if (err.stack) { @@ -882,12 +924,12 @@ export async function setupQlikSenseServerLicenseMonitor(config, logger) { ) { const sched = later.parse.text(config.get('Butler.qlikSenseLicense.serverLicenseMonitor.frequency')); later.setInterval(() => { - checkQlikSenseServerLicenseStatus(config, logger, false); + checkQlikSenseServerLicenseStatus(config, logger); }, sched); // Do an initial license check logger.verbose('Doing initial Qlik Sense server license check'); - checkQlikSenseServerLicenseStatus(config, logger, true); + checkQlikSenseServerLicenseStatus(config, logger); } } catch (err) { logger.error(`QLIKSENSE SERVER LICENSE MONITOR INIT: ${err}`); diff --git a/src/lib/webhook_notification.js b/src/lib/webhook_notification.js index 7808fb4b..f694a5e0 100644 --- a/src/lib/webhook_notification.js +++ b/src/lib/webhook_notification.js @@ -9,7 +9,36 @@ import getAppOwner from '../qrs_util/get_app_owner.js'; let rateLimiterMemoryFailedReloads; let rateLimiterMemoryAbortedReloads; let rateLimiterMemoryServiceMonitor; +let rateLimiterQlikSenseServerLicenseMonitor; +let rateLimiterQlikSenseServerLicenseExpiryAlert; +// Rate limiter for server license webhook +if (globals.config.has('Butler.webhookNotification.qlikSenseServerLicenseMonitor.rateLimit')) { + rateLimiterQlikSenseServerLicenseMonitor = new RateLimiterMemory({ + points: 1, + duration: globals.config.get('Butler.webhookNotification.qlikSenseServerLicenseMonitor.rateLimit'), + }); +} else { + rateLimiterQlikSenseServerLicenseMonitor = new RateLimiterMemory({ + points: 1, + duration: 300, + }); +} + +// Rate limiter for Qlik Sense server license expiry alert webhook +if (globals.config.has('Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.rateLimit')) { + rateLimiterQlikSenseServerLicenseExpiryAlert = new RateLimiterMemory({ + points: 1, + duration: globals.config.get('Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.rateLimit'), + }); +} else { + rateLimiterQlikSenseServerLicenseExpiryAlert = new RateLimiterMemory({ + points: 1, + duration: 300, + }); +} + +// if (globals.config.has('Butler.webhookNotification.reloadTaskFailure.rateLimit')) { rateLimiterMemoryFailedReloads = new RateLimiterMemory({ points: 1, @@ -146,6 +175,36 @@ function getOutgoingWebhookServiceMonitorConfig() { } } +function getOutgoingWebhookQlikSenseServerLicenseMonitorConfig() { + try { + return { + event: 'Qlik Sense server license status', + rateLimit: globals.config.has('Butler.webhookNotification.qlikSenseServerLicenseMonitor.rateLimit') + ? globals.config.get('Butler.webhookNotification.qlikSenseServerLicenseMonitor.rateLimit') + : '', + webhooks: globals.config.get('Butler.webhookNotification.qlikSenseServerLicenseMonitor.webhooks'), + }; + } catch (err) { + globals.logger.error(`WEBHOOK OUT QLIK SENSE SERVER LICENSE MONITOR: ${err}`); + return false; + } +} + +function getOutgoingWebhookQlikSenseServerLicenseExpiryAlertConfig() { + try { + return { + event: 'Qlik Sense server license expiry alert', + rateLimit: globals.config.has('Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.rateLimit') + ? globals.config.get('Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.rateLimit') + : '', + webhooks: globals.config.get('Butler.webhookNotification.qlikSenseServerLicenseExpiryAlert.webhooks'), + }; + } catch (err) { + globals.logger.error(`WEBHOOK OUT QLIK SENSE SERVER LICENSE EXPIRY ALERT: ${err}`); + return false; + } +} + async function sendOutgoingWebhook(webhookConfig, reloadParams) { try { // webhookConfig.webhooks contains an array of all outgoing webhooks that should be processed @@ -536,6 +595,175 @@ async function sendOutgoingWebhookServiceMonitor(webhookConfig, serviceParams) { } } +async function sendOutgoingWebhookQlikSenseServerLicense(webhookConfig, serverLicenseInfo) { + try { + // webhookConfig.webhooks contains an array of all outgoing webhooks that should be processed + + // Is webhookConfig.webhooks non-null, i.e. are there any webhooiks to process? + if (webhookConfig.webhooks) { + // eslint-disable-next-line no-restricted-syntax + for (const webhook of webhookConfig.webhooks) { + globals.logger.info(`WEBHOOKOUT QLIK SENSE SERVER LICENSE MONITOR: Processing webhook "${webhook.description}"`); + globals.logger.debug(`WEBHOOKOUT QLIK SENSE SERVER LICENSE MONITOR: Webhook details ${JSON.stringify(webhook)}`); + + // Only process the webhook if all required info is available + let lowercaseMethod = null; + let url = null; + let axiosRequest = null; + + try { + // 1. Make sure the webhook URL is a valid URL. + // If the URL is not valid an error will be thrown + url = new URL(webhook.webhookURL); + + // 2. Make sure the HTTP method is one of the supported ones + lowercaseMethod = webhook.httpMethod.toLowerCase(); + if (lowercaseMethod !== 'get' && lowercaseMethod !== 'post' && lowercaseMethod !== 'put') { + throw new Error(`Invalid HTTP method in outgoing webhook: ${webhook.httpMethod}`); + } + + // 3. If a custom certificate is specified, make sure it and related settings are valid + if (webhook.cert && webhook.cert.enable === true) { + // Make sure webhook.cert.rejectUnauthorized is a boolean + if (typeof webhook.cert.rejectUnauthorized !== 'boolean') { + throw new Error( + 'WEBHOOKOUT QLIK SENSE SERVER LICENSE MONITOR: Webhook cert.rejectUnauthorized property should be a boolean ' + ); + } + + // Make sure CA cert file in webhook.cert.certCA is a string + if (typeof webhook.cert.certCA !== 'string') { + throw new Error( + 'WEBHOOKOUT QLIK SENSE SERVER LICENSE MONITOR: Webhook cert.certCA property should be a string' + ); + } + + // Make sure the CA cert file exists + if (!fs.existsSync(webhook.cert.certCA)) { + throw new Error(`WEBHOOKOUT QLIK SENSE SERVER LICENSE MONITOR: CA cert file not found: ${webhook.cert.certCA}`); + } + } + } catch (err) { + globals.logger.error( + `WEBHOOKOUT QLIK SENSE SERVER LICENSE MONITOR: ${err}. Invalid outgoing webhook config: ${JSON.stringify( + webhook, + null, + 2 + )}` + ); + throw err; + } + + globals.logger.debug(`WEBHOOKOUT QLIK SENSE SERVER LICENSE MONITOR: Webhook config is valid: ${JSON.stringify(webhook)}`); + + axiosRequest = { + timeout: 10000, + }; + + if (lowercaseMethod === 'get') { + // Build parameter string for GET call + const params = new URLSearchParams(); + params.append('event', serverLicenseInfo.event); + params.append('licenseExpired', serverLicenseInfo.licenseExpired); + params.append('expiryDateStr', serverLicenseInfo.expiryDateStr); + params.append('daysUntilExpiry', serverLicenseInfo.daysUntilExpiry); + + url.search = params.toString(); + + globals.logger.silly(`WEBHOOKOUT QLIK SENSE SERVER LICENSE MONITOR: Final GET webhook URL: ${url.toString()}`); + + axiosRequest.method = 'get'; + axiosRequest.url = url.toString(); + + // If a custom certificate is specified, add it to the axios request + // Create a new https agent with the custom certificate + if (webhook.cert && webhook.cert.enable === true) { + // Read CA cert + const caCert = fs.readFileSync(webhook.cert.certCA); + + // Creating an HTTPS agent with the CA certificate and rejectUnauthorized flag + const agent = new https.Agent({ ca: caCert, rejectUnauthorized: webhook.cert.rejectUnauthorized }); + + // Add agent to Axios config + axiosRequest.httpsAgent = agent; + } + } else if (lowercaseMethod === 'put') { + // Build body for PUT call + axiosRequest = { + method: 'put', + url: url.toString(), + data: { + event: serverLicenseInfo.event, + licenseExpired: serverLicenseInfo.licenseExpired, + expiryDateStr: serverLicenseInfo.expiryDateStr, + daysUntilExpiry: serverLicenseInfo.daysUntilExpiry, + }, + headers: { 'Content-Type': 'application/json' }, + }; + + // If a custom certificate is specified, add it to the axios request + // Create a new https agent with the custom certificate + if (webhook.cert && webhook.cert.enable === true) { + // Read CA cert + const caCert = fs.readFileSync(webhook.cert.certCA); + + // Creating an HTTPS agent with the CA certificate and rejectUnauthorized flag + const agent = new https.Agent({ ca: caCert, rejectUnauthorized: webhook.cert.rejectUnauthorized }); + + // Add agent to Axios config + axiosRequest.httpsAgent = agent; + } + } else if (lowercaseMethod === 'post') { + // Build body for POST call + axiosRequest = { + method: 'post', + url: url.toString(), + data: { + event: serverLicenseInfo.event, + licenseExpired: serverLicenseInfo.licenseExpired, + expiryDateStr: serverLicenseInfo.expiryDateStr, + daysUntilExpiry: serverLicenseInfo.daysUntilExpiry, + }, + headers: { 'Content-Type': 'application/json' }, + }; + + // If a custom certificate is specified, add it to the axios request + // Create a new https agent with the custom certificate + if (webhook.cert && webhook.cert.enable === true) { + // Read CA cert + const caCert = fs.readFileSync(webhook.cert.certCA); + + // Creating an HTTPS agent with the CA certificate and rejectUnauthorized flag + const agent = new https.Agent({ ca: caCert, rejectUnauthorized: webhook.cert.rejectUnauthorized }); + + // Add agent to Axios config + axiosRequest.httpsAgent = agent; + } + } + + // eslint-disable-next-line no-await-in-loop + const response = await axios.request(axiosRequest); + globals.logger.debug(`WEBHOOKOUT QLIK SENSE SERVER LICENSE MONITOR: Webhook response: ${response}`); + } + } else { + globals.logger.info('WEBHOOKOUT QLIK SENSE SERVER LICENSE MONITOR: No outgoing webhooks to process'); + } + } catch (err) { + if (err.message) { + globals.logger.error(`WEBHOOKOUT QLIK SENSE SERVER LICENSE MONITOR 1 message: ${err.message}`); + } + + if (err.stack) { + globals.logger.error(`WEBHOOKOUT QLIK SENSE SERVER LICENSE MONITOR 1 stack: ${err.stack}`); + } + + // If neither message nor stack is available, just log the error object + if (!err.message && !err.stack) { + globals.logger.error(`WEBHOOKOUT QLIK SENSE SERVER LICENSE MONITOR 1: ${JSON.stringify(err, null, 2)}`); + } + } +} + export function sendReloadTaskFailureNotificationWebhook(reloadParams) { rateLimiterMemoryFailedReloads .consume(reloadParams.taskId, 1) @@ -639,3 +867,87 @@ export function sendServiceMonitorWebhook(svc) { globals.logger.verbose(`WEBHOOK OUT RELOAD TASK FAILED: Rate limiting details "${JSON.stringify(rateLimiterRes, null, 2)}"`); }); } + +// Function to call webhook with Qlik Sense server license info +// Parameters is an object: +// { +// event: string +// licenseExpired: boolean +// expiryDateStr: string +// daysUntilExpiry: number +// } +export async function callQlikSenseServerLicenseWebhook(serverLicenseInfo) { + // Do deep copy of serverLicenseInfo + const serverLicenseInfoCopy = JSON.parse(JSON.stringify(serverLicenseInfo)); + + // Dispatch depending on serverLicenseInfo.event + if (serverLicenseInfo.event === 'server license status') { + rateLimiterQlikSenseServerLicenseMonitor + .consume('license-monitor', 1) + .then(async (rateLimiterRes) => { + try { + globals.logger.info( + `WEBHOOK OUT QLIK SENSE SERVER LICENSE MONITOR: Rate limiting check passed for Qlik Sense server license monitor notification` + ); + globals.logger.verbose( + `WEBHOOK OUT QLIK SENSE SERVER LICENSE MONITOR: Rate limiting details "${JSON.stringify(rateLimiterRes, null, 2)}"` + ); + + // Make sure outgoing webhooks are enabled in the config file and that we have all required settings + const webhookConfig = getOutgoingWebhookQlikSenseServerLicenseMonitorConfig(); + if (webhookConfig === false) { + return 1; + } + + await sendOutgoingWebhookQlikSenseServerLicense(webhookConfig, serverLicenseInfoCopy); + } catch (err) { + globals.logger.error(`WEBHOOK OUT QLIK SENSE SERVER LICENSE MONITOR: ${err}`); + } + return 0; + }) + .catch((rateLimiterRes) => { + globals.logger.verbose( + `WEBHOOK OUT QLIK SENSE SERVER LICENSE MONITOR: Rate limiting failed. Not sending Qlik Sense server license monitor notification via outgoing webhook` + ); + globals.logger.verbose( + `WEBHOOK OUT QLIK SENSE SERVER LICENSE MONITOR: Rate limiting details "${JSON.stringify(rateLimiterRes, null, 2)}"` + ); + }); + } else if ( + serverLicenseInfo.event === 'server license has expired alert' || + serverLicenseInfo.event === 'server license about to expire alert' + ) { + // + rateLimiterQlikSenseServerLicenseExpiryAlert + .consume('license-alert', 1) + .then(async (rateLimiterRes) => { + try { + globals.logger.info( + `WEBHOOK OUT QLIK SENSE SERVER LICENSE EXPIRY ALERT: Rate limiting check passed for Qlik Sense server license expiry alert` + ); + globals.logger.verbose( + `WEBHOOK OUT QLIK SENSE SERVER LICENSE EXPIRY ALERT: Rate limiting details "${JSON.stringify(rateLimiterRes, null, 2)}"` + ); + + // Make sure outgoing webhooks are enabled in the config file and that we have all required settings + const webhookConfig = getOutgoingWebhookQlikSenseServerLicenseExpiryAlertConfig(); + if (webhookConfig === false) { + return 1; + } + + await sendOutgoingWebhookQlikSenseServerLicense(webhookConfig, serverLicenseInfoCopy); + } catch (err) { + globals.logger.error(`WEBHOOK OUT QLIK SENSE SERVER LICENSE EXPIRY ALERT: ${err}`); + } + return 0; + }) + .catch((rateLimiterRes) => { + globals.logger.verbose( + `WEBHOOK OUT QLIK SENSE SERVER LICENSE EXPIRY ALERT: Rate limiting failed. Not sending Qlik Sense server license expiry alert via outgoing webhook` + ); + globals.logger.verbose( + `WEBHOOK OUT QLIK SENSE SERVER LICENSE EXPIRY ALERT: Rate limiting details "${JSON.stringify(rateLimiterRes, null, 2)}"` + ); + }); + } +}