diff --git a/.gitignore b/.gitignore index 41e616a..7c19fe7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ qvfs qvfs_1 qvfs_2 qvfs_3 +qvfs_tmp src/node_modules/* diff --git a/src/ctrl-q.js b/src/ctrl-q.js index 9349e02..9b50828 100644 --- a/src/ctrl-q.js +++ b/src/ctrl-q.js @@ -22,6 +22,8 @@ import importAppFromFile from './lib/cmd/importapp.js'; import exportAppToFile from './lib/cmd/exportapp.js'; import testConnection from './lib/cmd/testconnection.js'; import visTask from './lib/cmd/vistask.js'; +import getSessions from './lib/cmd/getsessions.js'; +import deleteSessions from './lib/cmd/deletesessions.js'; import { sharedParamAssertOptions, @@ -38,6 +40,8 @@ import { taskImportAssertOptions, appImportAssertOptions, appExportAssertOptions, + getSessionsAssertOptions, + deleteSessionsAssertOptions, } from './lib/util/assert-options.js'; const program = new Command(); @@ -849,6 +853,84 @@ const program = new Command(); .option('--vis-host ', 'host for visualisation server', 'localhost') .option('--vis-port ', 'port for visualisation server', '3000'); + // Get proxy sessions + program + .command('sessions-get') + .description('get info about proxy sessions on one or more virtual proxies') + .action(async (options) => { + await sharedParamAssertOptions(options); + await getSessionsAssertOptions(options); + + getSessions(options, null); + }) + .addOption( + new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') + ) + + .requiredOption('--host ', 'Qlik Sense host (IP/FQDN) where Qlik Repository Service (QRS) is running') + .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix to access QRS via', '') + .option('--qrs-port ', 'Qlik Sense repository service (QRS) port (usually 4242)', '4242') + + .option('--session-virtual-proxy ', 'one or more Qlik Sense virtual proxies to get sessions for') + .option( + '--host-proxy ', + 'Qlik Sense hosts/proxies (IP/FQDN) to get sessions from. Must match the host names of the Sense nodes' + ) + .option('--qps-port ', 'Qlik Sense proxy service (QPS) port (usually 4243)', '4243') + + .requiredOption('--secure ', 'connection to Qlik Sense repository service is via https', true) + .requiredOption('--auth-user-dir ', 'user directory for user to connect with') + .requiredOption('--auth-user-id ', 'user ID for user to connect with') + + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') + .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') + .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem') + + .option('--output-format ', 'output format', 'json') + + .addOption( + new Option('-s, --sort-by ', 'column to sort output table by') + .choices(['prefix', 'proxyhost', 'proxyname', 'userdir', 'userid', 'username']) + .default('prefix') + ); + + // Delete proxy sessions + program + .command('sessions-delete') + .description('delete proxy session(s) on a specific virtual proxy and proxy service') + .action(async (options) => { + await sharedParamAssertOptions(options); + await deleteSessionsAssertOptions(options); + + deleteSessions(options); + }) + .addOption( + new Option('--log-level ', 'log level').choices(['error', 'warn', 'info', 'verbose', 'debug', 'silly']).default('info') + ) + .requiredOption('--host ', 'Qlik Sense host (IP/FQDN) where Qlik Repository Service (QRS) is running') + .requiredOption('--virtual-proxy ', 'Qlik Sense virtual proxy prefix to access QRS via', '') + .option('--qrs-port ', 'Qlik Sense repository service (QRS) port (usually 4242)', '4242') + + .option('--session-id ', 'session IDs to delete') + .requiredOption('--session-virtual-proxy ', 'Qlik Sense virtual proxy (prefix) to delete proxy session(s) on', '') + .requiredOption( + '--host-proxy ', + 'Qlik Sense proxy (IP/FQDN) where sessions should be deleted. Must match the host name of a Sense node' + ) + .option('--qps-port ', 'Qlik Sense proxy service (QPS) port (usually 4243)', '4243') + + .requiredOption('--secure ', 'connection to Qlik Sense repository service is via https', true) + .requiredOption('--auth-user-dir ', 'user directory for user to connect with') + .requiredOption('--auth-user-id ', 'user ID for user to connect with') + + .addOption(new Option('-a, --auth-type ', 'authentication type').choices(['cert']).default('cert')) + .option('--auth-cert-file ', 'Qlik Sense certificate file (exported from QMC)', './cert/client.pem') + .option('--auth-cert-key-file ', 'Qlik Sense certificate key file (exported from QMC)', './cert/client_key.pem') + .option('--auth-root-cert-file ', 'Qlik Sense root certificate file (exported from QMC)', './cert/root.pem'); + + // .option('--dry-run', 'do a dry run, i.e. do not delete any sessions - just show what would be deleted') + // Parse command line params await program.parseAsync(process.argv); })(); diff --git a/src/lib/cmd/deletesessions.js b/src/lib/cmd/deletesessions.js new file mode 100644 index 0000000..2addfc8 --- /dev/null +++ b/src/lib/cmd/deletesessions.js @@ -0,0 +1,36 @@ +import { deleteSessionsFromQSEoWIds } from '../util/session.js'; +import { logger, setLoggingLevel, isPkg, execPath } from '../../globals.js'; +import { catchLog } from '../util/log.js'; + +/** + * Delete Qlik Sense proxy sessions + * @param {object} options - Options object + */ +const deleteSessions = async (options) => { + try { + // Set log level + setLoggingLevel(options.logLevel); + + logger.verbose(`Ctrl-Q was started as a stand-alone binary: ${isPkg}`); + logger.verbose(`Ctrl-Q was started from ${execPath}`); + + logger.info('Delete Qlik Sense proxy sessions'); + logger.debug(`Options: ${JSON.stringify(options, null, 2)}`); + + logger.info(`Deleting sessions on proxy "${options.hostProxy}", virtual proxy "${options.sessionVirtualProxy}"`); + const deleteResult = await deleteSessionsFromQSEoWIds(options); + + if (deleteResult === false) { + logger.error('Error deleting proxy sessions.'); + return false; + } + + return true; + } catch (err) { + catchLog(`Error deleting proxy sessions from host "${options.hostProxy}", virtual proxy "${options.sessionVirtualProxy}"`, err); + + return false; + } +}; + +export default deleteSessions; diff --git a/src/lib/cmd/getsessions.js b/src/lib/cmd/getsessions.js new file mode 100644 index 0000000..a1ccb2c --- /dev/null +++ b/src/lib/cmd/getsessions.js @@ -0,0 +1,194 @@ +import { table } from 'table'; +import { getSessionsFromQseow } from '../util/session.js'; +import { logger, setLoggingLevel, isPkg, execPath } from '../../globals.js'; +import { catchLog } from '../util/log.js'; + +const consoleTableConfig = { + border: { + topBody: `─`, + topJoin: `┬`, + topLeft: `┌`, + topRight: `┐`, + + bottomBody: `─`, + bottomJoin: `┴`, + bottomLeft: `└`, + bottomRight: `┘`, + + bodyLeft: `│`, + bodyRight: `│`, + bodyJoin: `│`, + + joinBody: `─`, + joinLeft: `├`, + joinRight: `┤`, + joinJoin: `┼`, + }, + columns: { + // 3: { width: 40 }, + // 4: { width: 100 }, + // 5: { width: 30 }, + // 6: { width: 30 }, + }, +}; + +/** + * + * @param {*} options + */ +const getSessions = async (options) => { + try { + // Set log level + setLoggingLevel(options.logLevel); + + logger.verbose(`Ctrl-Q was started as a stand-alone binary: ${isPkg}`); + logger.verbose(`Ctrl-Q was started from ${execPath}`); + + logger.info('Get Qlik Sense proxy sessions'); + logger.debug(`Options: ${JSON.stringify(options, null, 2)}`); + + const sessionDataArray = await getSessionsFromQseow(options, null); + + if (sessionDataArray === false || sessionDataArray === undefined) { + logger.error(`Error getting proxy sessions from from QSEoW`); + return false; + } + + // Build table or json, depending on output format + if (options.outputFormat === 'table') { + const sessionsTable = []; + sessionsTable.push([ + 'Virtual proxy description', + 'Virtual proxy prefix', + 'Virtual proxy session cookie header', + 'Linked proxy service', + 'Load balancing nodes', + 'Session user directory', + 'Session user ID', + 'Session user name', + 'Session attributes', + 'Session ID', + ]); + + // Get total number of sessions + // Sum the number of entries in each sessionDataArray.sessions array + const totalSessions = sessionDataArray.reduce((acc, vp) => acc + vp.sessions.length, 0); + + // Get sessions per proxy host + // First get all unique proxy hosts + const uniqueProxyHosts = [...new Set(sessionDataArray.map((vp) => vp.hostProxy))]; + const sessionsPerProxyHost = uniqueProxyHosts.map((host) => { + const sessions = sessionDataArray.filter((vp) => vp.hostProxy === host).reduce((acc, vp) => acc + vp.sessions.length, 0); + return { host, sessions }; + }); + + // Get mapping between proxy host and proxy host name + // Reduce to uniqur mappings + const proxyHostNameMap = sessionDataArray.map((vp) => ({ hostProxy: vp.hostProxy, hostProxyName: vp.hostProxyName })); + const uniqueProxyHostNameMap = proxyHostNameMap.filter( + (vp, index, self) => index === self.findIndex((t) => t.hostProxy === vp.hostProxy) + ); + + // Build text for table header + let headerText = `-- Sessions per virtual proxy and proxy services --\n\nTotal number of sessions: ${totalSessions}\n\n`; + + // Add sessions per proxy host + headerText += 'Sessions per proxy service:\n'; + sessionsPerProxyHost.forEach((p) => { + // Get name of proxy host + const proxyHostName = uniqueProxyHostNameMap.find((m) => m.hostProxy === p.host).hostProxyName; + headerText += ` ${proxyHostName}: ${p.host}: ${p.sessions}\n`; + }); + + consoleTableConfig.header = { + alignment: 'left', + content: headerText, + }; + + // Expand all session data into a single array of objects to make later sorting and filtering easier + const sessionsTabular = []; + sessionDataArray.forEach((vp) => { + vp.sessions.forEach((s) => { + const proxyHostName = uniqueProxyHostNameMap.find((m) => m.hostProxy === vp.hostProxy).hostProxyName; + const vpLoadBalancingNodes = vp.virtualproxy.loadBalancingServerNodes + .map((node) => `${node.name}: ${node.hostName}`) + .join('\n'); + + // session.Attributes is an array, where each element is an object with a single key-value pair + const attributes = s.Attributes.map((a) => { + // Convert object to string on the format "key: value" + const attr = Object.keys(a).map((key) => `${key}: ${a[key]}`)[0]; + + return attr; + }).join('\n'); + + sessionsTabular.push({ + vpDescription: vp.virtualproxy.description, + vpPrefix: vp.virtualproxy.prefix, + vpSessionCookieHeaderName: vp.virtualproxy.sessionCookieHeaderName, + proxyHost: vp.hostProxy, + proxyName: proxyHostName, + proxyFull: `${proxyHostName}:\n${vp.hostProxy}`, + loadBalancingNodes: vpLoadBalancingNodes, + userDir: s.UserDirectory, + userId: s.UserId, + userName: s.UserName === undefined || s.UserName === null ? '' : s.UserName, + attributes, + sessionId: s.SessionId, + }); + }); + }); + + // Sort the sessionDataArray as specified in the options.sortBy option + // Possible values are: 'prefix', 'proxyhost', 'proxyname' + if (options.sortBy !== undefined && options.sortBy !== null && options.sortBy !== '') { + if (options.sortBy === 'prefix') { + sessionsTabular.sort((a, b) => a.vpPrefix.localeCompare(b.vpPrefix)); + } else if (options.sortBy === 'proxyhost') { + sessionsTabular.sort((a, b) => a.proxyHost.localeCompare(b.proxyHost)); + } else if (options.sortBy === 'proxyname') { + sessionsTabular.sort((a, b) => a.proxyName.localeCompare(b.proxyName)); + } else if (options.sortBy === 'userdir') { + sessionsTabular.sort((a, b) => a.userDir.localeCompare(b.userDir)); + } else if (options.sortBy === 'userid') { + sessionsTabular.sort((a, b) => a.userId.localeCompare(b.userId)); + } else if (options.sortBy === 'username') { + sessionsTabular.sort((a, b) => a.userName.localeCompare(b.userName)); + } + } else { + logger.warn('--sort-by option is invalid. Use default sorting.'); + } + + // Add to table that will be printed to console + // eslint-disable-next-line no-restricted-syntax + for (const s of sessionsTabular) { + sessionsTable.push([ + s.vpDescription, + s.vpPrefix, + s.vpSessionCookieHeaderName, + s.proxyFull, + s.loadBalancingNodes, + s.userDir, + s.userId, + s.userName, + s.attributes, + s.sessionId, + ]); + } + + // Print table to console + logger.info(`\n${table(sessionsTable, consoleTableConfig)}`); + } else { + logger.error('Invalid --output-format option'); + return false; + } + + return sessionDataArray; + } catch (err) { + catchLog(`Error getting proxy sessions from host ${options.host}`, err); + + return false; + } +}; + +export default getSessions; diff --git a/src/lib/util/assert-options.js b/src/lib/util/assert-options.js index b8c0dbb..f57a314 100644 --- a/src/lib/util/assert-options.js +++ b/src/lib/util/assert-options.js @@ -1,6 +1,5 @@ import path from 'path'; -import { version as uuidVersion } from 'uuid'; -import { validate as uuidValidate } from 'uuid'; +import { version as uuidVersion,validate as uuidValidate } from 'uuid'; import fs from 'fs'; import { logger, execPath, mergeDirFilePath, verifyFileExists } from '../../globals.js'; @@ -294,3 +293,13 @@ export const variableDeleteAssertOptions = (options) => { process.exit(1); } }; + +// eslint-disable-next-line no-unused-vars +export const getSessionsAssertOptions = (options) => { + // +}; + +// eslint-disable-next-line no-unused-vars +export const deleteSessionsAssertOptions = (options) => { + // +} diff --git a/src/lib/util/proxy.js b/src/lib/util/proxy.js new file mode 100644 index 0000000..31128df --- /dev/null +++ b/src/lib/util/proxy.js @@ -0,0 +1,40 @@ +import axios from 'axios'; +import path from 'path'; +import { logger, execPath } from '../../globals.js'; +import setupQRSConnection from './qrs.js'; +import { catchLog } from './log.js'; + +const getProxiesFromQseow = async (options, sessionCookie) => { + logger.verbose(`Getting all proxies from QSEoW...`); + + // Make sure certificates exist + const fileCert = path.resolve(execPath, options.authCertFile); + const fileCertKey = path.resolve(execPath, options.authCertKeyFile); + + const axiosConfig = setupQRSConnection(options, { + method: 'get', + fileCert, + fileCertKey, + path: '/qrs/proxyservice/full', + sessionCookie: null, + }); + + // Get proxies from QRS + let proxies = []; + try { + const result = await axios.request(axiosConfig); + + if (result.status === 200) { + const response = JSON.parse(result.data); + proxies = response; + logger.info(`Successfully retrieved ${response.length} proxies from host ${options.host}`); + } + } catch (err) { + catchLog('GET VIRTUAL PROXIES FROM QSEoW', err); + return false; + } + + return proxies; +}; + +export default getProxiesFromQseow; diff --git a/src/lib/util/qps.js b/src/lib/util/qps.js new file mode 100644 index 0000000..0ab3561 --- /dev/null +++ b/src/lib/util/qps.js @@ -0,0 +1,77 @@ +import https from 'https'; +import { logger, generateXrfKey, readCert } from '../../globals.js'; + +const setupQPSConnection = (options, param) => { + // eslint-disable-next-line no-unused-vars + // Ensure valid http method + if (!param.method || (param.method.toLowerCase() !== 'get' && param.method.toLowerCase() !== 'delete')) { + logger.error(`Setting up connection to QPS. Invalid http method '${param.method}'. Exiting.`); + process.exit(1); + } + + // Port is specified slightly differently for different Ctrl-Q commands + const port = options.qpsPort === undefined ? options.port : options.qpsPort; + + // Get key for protecting against cross-site request forgery + const xrfKey = generateXrfKey(); + + let axiosConfig; + + // Use cerrificates be used for authentication + if (options.authType === 'cert') { + logger.debug(`Using certificates for authentication with QPS`); + + const httpsAgent = new https.Agent({ + rejectUnauthorized: false, + cert: readCert(param.fileCert), + key: readCert(param.fileCertKey), + }); + + axiosConfig = { + url: `${param.path}?xrfkey=${xrfKey}`, + method: param.method.toLowerCase(), + baseURL: `https://${param.hostProxy}:${port}`, + headers: { + 'x-qlik-xrfkey': xrfKey, + 'X-Qlik-User': `UserDirectory=${options.authUserDir};UserId=${options.authUserId}`, + }, + responseType: 'application/json', + responseEncoding: 'utf8', + httpsAgent, + timeout: 60000, + }; + + // If param.sessionCookie is set, add it to the headers + if (param.sessionCookie) { + axiosConfig.headers[param.sessionCookie.cookieName] = param.sessionCookie.cookieValue; + } + } else { + // Report error + logger.error(`Setting up connection to QPS. Invalid authentication type '${options.authType}'. Exiting.`); + + // Throw error + throw new Error(`Setting up connection to QPS. Invalid authentication type '${options.authType}'`); + } + + // Add message body (if any) + // if (param.body) { + // axiosConfig.data = param.body; + // } + + // Add extra headers (if any) + if (param.headers) { + axiosConfig.headers = { ...axiosConfig.headers, ...param.headers }; + } + + // Add parameters (if any) + if (param.queryParameters?.length > 0) { + // eslint-disable-next-line no-restricted-syntax + for (const queryParam of param.queryParameters) { + axiosConfig.url += `&${queryParam.name}=${queryParam.value}`; + } + } + + return axiosConfig; +}; + +export default setupQPSConnection; diff --git a/src/lib/util/qrs.js b/src/lib/util/qrs.js index cf797b8..5839e68 100644 --- a/src/lib/util/qrs.js +++ b/src/lib/util/qrs.js @@ -43,7 +43,7 @@ const setupQRSConnection = (options, param) => { let axiosConfig; // Should cerrificates be used for authentication? if (options.authType === 'cert') { - logger.verbose(`Using certificates for authentication with QRS`); + logger.debug(`Using certificates for authentication with QRS`); const httpsAgent = new https.Agent({ rejectUnauthorized: false, diff --git a/src/lib/util/session.js b/src/lib/util/session.js new file mode 100644 index 0000000..e145d17 --- /dev/null +++ b/src/lib/util/session.js @@ -0,0 +1,394 @@ +import axios from 'axios'; +import path from 'path'; +import { table } from 'table'; +import yesno from 'yesno'; +import { logger, execPath } from '../../globals.js'; +import setupQPSConnection from './qps.js'; +import setupQRSConnection from './qrs.js'; +import { catchLog } from './log.js'; +import getProxiesFromQseow from './proxy.js'; + +const consoleProxiesTableConfig = { + border: { + topBody: `─`, + topJoin: `┬`, + topLeft: `┌`, + topRight: `┐`, + + bottomBody: `─`, + bottomJoin: `┴`, + bottomLeft: `└`, + bottomRight: `┘`, + + bodyLeft: `│`, + bodyRight: `│`, + bodyJoin: `│`, + + joinBody: `─`, + joinLeft: `├`, + joinRight: `┤`, + joinJoin: `┼`, + }, + columns: { + // 3: { width: 40 }, + // 4: { width: 100 }, + // 5: { width: 30 }, + // 6: { width: 30 }, + }, +}; + +// Get sessions from Qlik Sense Enterprise on Windows (QSEoW) +// +// Sessions from one or more proxy services can be retrieved +// If the --host parameter is not set, sessions from all proxy services will be retrieved +// If one or more host names are specified, sessions from the proxy services on those hosts will be retrieved +// +// Returns an array of objects, each object representing a proxy service +// Each proxy service object contains the virtual proxy, the sessions and the linked proxies for that virtual proxy +// +// options - Options object +// logLevel - Log level +// host - Host where QRS is running (hostname or IP address) +// virtualProxy - Virtual proxy prefix where QRS is running +// qrsPort - Port where QRS is running +// sessionVirtualProxy - Array of virtual proxy prefixes for which sessions should be retrieved +// hostProxy - Array of proxies for which sessions should be retrieved (hostname or IP address) +// qpsPort - Port where QPS is running +// secure - Use https +// authUserDir - User directory for Qlik Sense user +// authUserId - User ID for Qlik Sense user +// authType - Authentication type. Only "cert" is allowed +// authCertFile - File name of certificate file +// authCertKeyFile - File name of certificate key file +// authRootCertFile - File name of root certificate file +// outputFormat - Output format for the command. json or table +// sortBy - Sort by column for table output +export const getSessionsFromQseow = async (options, sessionCookie) => { + logger.verbose(`Getting sessions from QSEoW...`); + + // Only cerrificates allowed for authentication + if (options.authType !== 'cert') { + logger.error(`Only certificates allowed for authentication with Qlik Proxy Service (QPS)`); + return false; + } + + // Make sure certificates exist + const fileCert = path.resolve(execPath, options.authCertFile); + const fileCertKey = path.resolve(execPath, options.authCertKeyFile); + + let axiosConfig; + let virtualProxiesToProcess = []; + + // Are there any virtual proxies specified for which sessions should be retrieved? + if (options.sessionVirtualProxy && options.sessionVirtualProxy.length > 0) { + // At least one virtual proxy is specified + // virtualProxy = options.virtualProxy; + + // Build filter string + // Virtual proxies are specified as an array of strings + // Filter format is: id eq 'vpName1' or id eq 'vpName2' or id eq 'vpName3' + const vpFilter = options.sessionVirtualProxy.map((vp) => `prefix eq '${vp}'`).join(' or '); + axiosConfig = setupQRSConnection(options, { + method: 'get', + fileCert, + fileCertKey, + path: '/qrs/virtualproxyconfig/full', + queryParameters: [{ name: 'filter', value: encodeURI(vpFilter) }], + }); + } else { + // No virtual proxies specified, get all of them from QRS + axiosConfig = setupQRSConnection(options, { + method: 'get', + fileCert, + fileCertKey, + path: '/qrs/virtualproxyconfig/full', + }); + } + + // Get virtual proxies from QRS + try { + logger.debug(`Config: ${JSON.stringify(axiosConfig)}`); + const result = await axios.request(axiosConfig); + + if (result.status === 200) { + const response = JSON.parse(result.data); + logger.info(`Successfully retrieved ${response.length} virtual proxies from host ${options.host}`); + + virtualProxiesToProcess = response; + } + } catch (err) { + catchLog('GET VIRTUAL PROXIES FROM QSEoW', err); + // resolve(false); + return false; + } + + let proxiesAvailable = []; + let proxiesToProcess = []; + try { + // Get all proxies from QRS + proxiesAvailable = await getProxiesFromQseow(options, sessionCookie); + + // Build table of all proxies, writing to console + // This will make it easier for users to know which host name that should be used when calling Ctrl-Q + const proxiesTable = []; + proxiesTable.push(['Name', 'Host name', 'Id', 'Linked virtual proxies']); + + consoleProxiesTableConfig.header = { + alignment: 'left', + content: `Available proxy services.\n\nNote: The "sessions-get" command will only work correctly if the correct --host parameter is used when calling Ctrl-Q.\nThe --host parameter should be one of the host names listed below.`, + }; + + // Loop over all proxies and build table + proxiesAvailable.forEach((proxy) => { + proxiesTable.push([ + proxy.serverNodeConfiguration.name, + proxy.serverNodeConfiguration.hostName, + proxy.id, + proxy.settings.virtualProxies.length, + ]); + }); + + // Print proxies table to console + logger.info(`Available Proxy services.\n${table(proxiesTable, consoleProxiesTableConfig)}`); + + // Make sure that the --host-proxy parameter is set to one of the host names listed in the table + // If the --host-proxy parameter is not set, get sessions from all proxies + // The --host-proxy parameter can contain one or more host names + if (options.hostProxy === undefined) { + proxiesToProcess = proxiesAvailable.map((p) => p.serverNodeConfiguration.hostName); + } else { + const proxyHostNames = proxiesAvailable.map((p) => p.serverNodeConfiguration.hostName); + + // Which of the proxy host names specified on the command line are valid? + const validHostParameters = options.hostProxy.filter((h) => { + if (proxyHostNames.includes(h)) { + return h; + } + logger.error( + `❌ The --host-proxy parameter is set to "${h}". Getting sessions from Sense only work correctly if the correct --host-proxy parameter is used when calling Ctrl-Q.\n\n===> Please use one or more of the following proxy host names: ${proxyHostNames.join(', ')}\n` + ); + return null; + }); + + if (validHostParameters.length === options.hostProxy.length) { + // All host names are valid + logger.info(`✅ All host names specified in the --host-proxy parameter are valid.`); + } else { + logger.error('Exiting'); + process.exit(1); + } + + proxiesToProcess = options.hostProxy; + } + } catch (err) { + catchLog('GET PROXIES FROM QSEoW', err); + return false; + } + + let sessions = []; + // Loop over virtual proxies and get sessions for each, but only if the linked proxy is in the list of proxies to process + // eslint-disable-next-line no-restricted-syntax + for (const vp of virtualProxiesToProcess) { + // Is this virtual proxy linked to at least one proxy? + const proxiesVirtualProxy = proxiesAvailable.filter((p) => p.settings.virtualProxies.find((q) => q.id === vp.id)); + logger.verbose( + `Virtual proxy "${vp.prefix}" (header="${vp.sessionCookieHeaderName}") is linked to ${proxiesVirtualProxy.length} proxies` + ); + + if (proxiesVirtualProxy.length === 0) { + logger.warn( + `Virtual proxy is not linked to any proxy. Prefix="${vp.prefix}", Session cookie header name="${vp.sessionCookieHeaderName}"` + ); + + continue; + } + + let sessionPerVirtualProxy = 0; + // Loop over all proxies linked to this virtual proxy, get the proxy sessions for each one + // eslint-disable-next-line no-restricted-syntax + for (const proxy of proxiesVirtualProxy) { + // Is this proxy in list of proxies to process? + if (proxiesToProcess.length > 0 && !proxiesToProcess.includes(proxy.serverNodeConfiguration.hostName)) { + logger.verbose( + `Proxy "${proxy.serverNodeConfiguration.hostName}" is not in list of proxies to process. Skipping for virtual proxy "${vp.prefix}"...` + ); + continue; + } + + // Get sessions for this virtual proxy + axiosConfig = setupQPSConnection(options, { + hostProxy: proxy.serverNodeConfiguration.hostName, + method: 'get', + fileCert, + fileCertKey, + path: `/qps/${vp.prefix}/session`, + sessionCookie: null, + }); + + try { + // eslint-disable-next-line no-await-in-loop + const result = await axios.request(axiosConfig); + + if (result.status === 200) { + const response = JSON.parse(result.data); + logger.verbose( + `Virtual proxy prefix/session header "${vp.prefix}" / "${vp.sessionCookieHeaderName}" : ${response.length} sessions on proxy host "${proxy.serverNodeConfiguration.hostName}"` + ); + + // Save sessions in array + sessions = sessions.concat({ + virtualproxy: vp, + sessions: response, + hostProxy: proxy.serverNodeConfiguration.hostName, + hostProxyName: proxy.serverNodeConfiguration.name, + }); + sessionPerVirtualProxy += response.length; + } + } catch (err) { + catchLog('GET SESSIONS FROM QSEoW', err); + return false; + } + } + + // Log summary of sessions for this virtual proxy + logger.verbose(`Total sessions across all linked proxies for virtual proxy "${vp.prefix}": ${sessionPerVirtualProxy}`); + } + + return sessions; +}; + +// Delete proxy sessions from Qlik Sense Enterprise on Windows (QSEoW) based on session IDs +// If no session IDs are specified, all sessions for the specified virtual proxy and proxy service will be deleted after continue-yes-no prompt +// By design this function will only delete sessions for one virtual proxy and one proxy service +export const deleteSessionsFromQSEoWIds = async (options) => { + logger.verbose(`Deleting proxy sessions from QSEoW...`); + + // Only cerrificates allowed for authentication + if (options.authType !== 'cert') { + logger.error(`Only certificates allowed for authentication with Qlik Proxy Service (QPS)`); + return false; + } + + // Make sure certificates exist + const fileCert = path.resolve(execPath, options.authCertFile); + const fileCertKey = path.resolve(execPath, options.authCertKeyFile); + + try { + const sessionDelete = []; + + // Get all sessions for this virtual proxy / proxy service + const vpWithSessions = await getSessionsFromQseow({ + ...options, + hostProxy: [options.hostProxy], + sessionVirtualProxy: [options.sessionVirtualProxy], + }); + + // Are there any sessions IDs specified? + // If not, show warning that all sessions for this vp/proxy will be deleted + if (options.sessionId === undefined || options.sessionId.length === 0) { + logger.info(); + const ok = await yesno({ + question: ` No session IDs specified, meaning that all existing sessions will be deleted for proxy "${options.hostProxy}" and virtual proxy "${options.sessionVirtualProxy}".\n\n Are you sure you want to continue? (y/n)`, + }); + logger.info(); + + if (ok === false) { + logger.info('❌ Not deleting any sessions.'); + process.exit(1); + } else { + logger.info('Deleting sessions...'); + + // Build array of retrieved session IDs and metadata + vpWithSessions.forEach((vp) => { + vp.sessions.forEach((s) => { + sessionDelete.push({ + hostProxy: vp.hostProxy, + hostProxyName: vp.hostProxyName, + sessionId: s.SessionId, + userDirectory: s.UserDirectory, + userId: s.UserId, + userName: s.UserName, + }); + }); + }); + } + } else { + // Use session IDs specified on command line + // eslint-disable-next-line no-restricted-syntax + for (const s of options.sessionId) { + // eslint-disable-next-line no-restricted-syntax + for (const vp of vpWithSessions) { + if (vp.sessions.find((x) => x.SessionId === s)) { + const sessionObject = { + sessionId: s, + hostProxy: vp.hostProxy, + hostProxyName: vp.hostProxyName, + }; + + // Dress with additional session metadata (userDirectory and userId) from vpWithSessions.sessions + const session = vp.sessions.find((x) => x.SessionId === s); + if (session) { + sessionObject.userDirectory = session.UserDirectory; + sessionObject.userId = session.UserId; + sessionObject.userName = session.UserName; + } + + sessionDelete.push(sessionObject); + } else { + logger.warn(`Session ID "${s}" not found`); + } + } + } + } + + let deleteCounter = 0; + + // Loop over all session IDs and delete each one + // eslint-disable-next-line no-restricted-syntax + for (const s of sessionDelete) { + logger.verbose( + `Deleting session ID "${s.sessionId}" on proxy "${options.hostProxy}", virtual proxy "${options.sessionVirtualProxy}"...` + ); + logger.debug(`Session metadata: ${JSON.stringify(s, null, 2)}`); + + try { + const axiosConfig = setupQPSConnection(options, { + hostProxy: options.hostProxy, + method: 'delete', + fileCert, + fileCertKey, + path: `/qps/${options.sessionVirtualProxy}/session/${s.sessionId}`, + sessionCookie: null, + }); + + // eslint-disable-next-line no-await-in-loop + const result = await axios.request(axiosConfig); + + if (result.status === 200) { + if (s.userName === undefined || s.userName === null) { + logger.info(`Session ID "${s.sessionId}" successfully deleted. User: ${s.userDirectory}\\${s.userId}`); + } else { + logger.info( + `Session ID "${s.sessionId}" successfully deleted. User: ${s.userDirectory}\\${s.userId} (${s.userName})` + ); + } + deleteCounter += 1; + } + } catch (err) { + if (err?.response?.status === 404) { + logger.warn(`Session ID "${s.sessionId}" not found`); + } else { + catchLog('DELETE PROXY SESSIONS FROM QSEoW', err); + return false; + } + } + } + + logger.info(''); + logger.info(`Deleted ${deleteCounter} sessions`); + return true; + } catch (err) { + catchLog('DELETE PROXY SESSIONS FROM QSEoW', err); + return false; + } +};