const tcpp = require("tcp-ping"); const ping = require("@louislam/ping"); const { R } = require("redbean-node"); const { log, genSecret } = require("../src/util"); const passwordHash = require("./password-hash"); const { Resolver } = require("dns"); const childProcess = require("child_process"); const iconv = require("iconv-lite"); const chardet = require("chardet"); const mqtt = require("mqtt"); const chroma = require("chroma-js"); const { badgeConstants } = require("./config"); const mssql = require("mssql"); const { Client } = require("pg"); const postgresConParse = require("pg-connection-string").parse; const mysql = require("mysql2"); const { NtlmClient } = require("axios-ntlm"); const { Settings } = require("./settings"); const grpc = require("@grpc/grpc-js"); const protojs = require("protobufjs"); const radiusClient = require("node-radius-client"); const redis = require("redis"); const { dictionaries: { rfc2865: { file, attributes }, }, } = require("node-radius-utils"); const dayjs = require("dayjs"); const isWindows = process.platform === /^win/.test(process.platform); /** * Init or reset JWT secret * @returns {Promise} */ exports.initJWTSecret = async () => { let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [ "jwtSecret", ]); if (!jwtSecretBean) { jwtSecretBean = R.dispense("setting"); jwtSecretBean.key = "jwtSecret"; } jwtSecretBean.value = passwordHash.generate(genSecret()); await R.store(jwtSecretBean); return jwtSecretBean; }; /** * Send TCP request to specified hostname and port * @param {string} hostname Hostname / address of machine * @param {number} port TCP port to test * @returns {Promise} Maximum time in ms rounded to nearest integer */ exports.tcping = function (hostname, port) { return new Promise((resolve, reject) => { tcpp.ping({ address: hostname, port: port, attempts: 1, }, function (err, data) { if (err) { reject(err); } if (data.results.length >= 1 && data.results[0].err) { reject(data.results[0].err); } resolve(Math.round(data.max)); }); }); }; /** * Ping the specified machine * @param {string} hostname Hostname / address of machine * @returns {Promise} Time for ping in ms rounded to nearest integer */ exports.ping = async (hostname) => { try { return await exports.pingAsync(hostname); } catch (e) { // If the host cannot be resolved, try again with ipv6 if (e.message.includes("service not known")) { return await exports.pingAsync(hostname, true); } else { throw e; } } }; /** * Ping the specified machine * @param {string} hostname Hostname / address of machine to ping * @param {boolean} ipv6 Should IPv6 be used? * @returns {Promise} Time for ping in ms rounded to nearest integer */ exports.pingAsync = function (hostname, ipv6 = false) { return new Promise((resolve, reject) => { ping.promise.probe(hostname, { v6: ipv6, min_reply: 1, timeout: 10, }).then((res) => { // If ping failed, it will set field to unknown if (res.alive) { resolve(res.time); } else { if (isWindows) { reject(new Error(exports.convertToUTF8(res.output))); } else { reject(new Error(res.output)); } } }).catch((err) => { reject(err); }); }); }; /** * MQTT Monitor * @param {string} hostname Hostname / address of machine to test * @param {string} topic MQTT topic * @param {string} okMessage Expected result * @param {Object} [options={}] MQTT options. Contains port, username, * password and interval (interval defaults to 20) * @returns {Promise} */ exports.mqttAsync = function (hostname, topic, okMessage, options = {}) { return new Promise((resolve, reject) => { const { port, username, password, interval = 20 } = options; // Adds MQTT protocol to the hostname if not already present if (!/^(?:http|mqtt)s?:\/\//.test(hostname)) { hostname = "mqtt://" + hostname; } const timeoutID = setTimeout(() => { log.debug("mqtt", "MQTT timeout triggered"); client.end(); reject(new Error("Timeout")); }, interval * 1000 * 0.8); log.debug("mqtt", "MQTT connecting"); let client = mqtt.connect(hostname, { port, username, password }); client.on("connect", () => { log.debug("mqtt", "MQTT connected"); try { log.debug("mqtt", "MQTT subscribe topic"); client.subscribe(topic); } catch (e) { client.end(); clearTimeout(timeoutID); reject(new Error("Cannot subscribe topic")); } }); client.on("error", (error) => { client.end(); clearTimeout(timeoutID); reject(error); }); client.on("message", (messageTopic, message) => { if (messageTopic === topic) { client.end(); clearTimeout(timeoutID); if (okMessage != null && okMessage !== "" && message.toString() !== okMessage) { reject(new Error(`Message Mismatch - Topic: ${messageTopic}; Message: ${message.toString()}`)); } else { resolve(`Topic: ${messageTopic}; Message: ${message.toString()}`); } } }); }); }; /** * Use NTLM Auth for a http request. * @param {Object} options The http request options * @param {Object} ntlmOptions The auth options * @returns {Promise<(string[]|Object[]|Object)>} */ exports.httpNtlm = function (options, ntlmOptions) { return new Promise((resolve, reject) => { let client = NtlmClient(ntlmOptions); client(options) .then((resp) => { resolve(resp); }) .catch((err) => { reject(err); }); }); }; /** * Resolves a given record using the specified DNS server * @param {string} hostname The hostname of the record to lookup * @param {string} resolverServer The DNS server to use * @param {string} resolverPort Port the DNS server is listening on * @param {string} rrtype The type of record to request * @returns {Promise<(string[]|Object[]|Object)>} */ exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype) { const resolver = new Resolver(); // Remove brackets from IPv6 addresses so we can re-add them to // prevent issues with ::1:5300 (::1 port 5300) resolverServer = resolverServer.replace("[", "").replace("]", ""); resolver.setServers([ `[${resolverServer}]:${resolverPort}` ]); return new Promise((resolve, reject) => { if (rrtype === "PTR") { resolver.reverse(hostname, (err, records) => { if (err) { reject(err); } else { resolve(records); } }); } else { resolver.resolve(hostname, rrtype, (err, records) => { if (err) { reject(err); } else { resolve(records); } }); } }); }; /** * Run a query on SQL Server * @param {string} connectionString The database connection string * @param {string} query The query to validate the database with * @returns {Promise<(string[]|Object[]|Object)>} */ exports.mssqlQuery = async function (connectionString, query) { let pool; try { pool = new mssql.ConnectionPool(connectionString); await pool.connect(); await pool.request().query(query); pool.close(); } catch (e) { if (pool) { pool.close(); } throw e; } }; /** * Run a query on Postgres * @param {string} connectionString The database connection string * @param {string} query The query to validate the database with * @returns {Promise<(string[]|Object[]|Object)>} */ exports.postgresQuery = function (connectionString, query) { return new Promise((resolve, reject) => { const config = postgresConParse(connectionString); if (config.password === "") { // See https://github.com/brianc/node-postgres/issues/1927 return reject(new Error("Password is undefined.")); } const client = new Client({ connectionString }); client.connect(); return client.query(query) .then(res => { resolve(res); }) .catch(err => { reject(err); }) .finally(() => { client.end(); }); }); }; /** * Run a query on MySQL/MariaDB * @param {string} connectionString The database connection string * @param {string} query The query to validate the database with * @returns {Promise<(string[]|Object[]|Object)>} */ exports.mysqlQuery = function (connectionString, query) { return new Promise((resolve, reject) => { const connection = mysql.createConnection(connectionString); connection.promise().query(query) .then(res => { resolve(res); }) .catch(err => { reject(err); }) .finally(() => { connection.end(); }); }); }; /** * Query radius server * @param {string} hostname Hostname of radius server * @param {string} username Username to use * @param {string} password Password to use * @param {string} calledStationId ID of called station * @param {string} callingStationId ID of calling station * @param {string} secret Secret to use * @param {number} [port=1812] Port to contact radius server on * @returns {Promise} */ exports.radius = function ( hostname, username, password, calledStationId, callingStationId, secret, port = 1812, ) { const client = new radiusClient({ host: hostname, hostPort: port, dictionaries: [ file ], }); return client.accessRequest({ secret: secret, attributes: [ [ attributes.USER_NAME, username ], [ attributes.USER_PASSWORD, password ], [ attributes.CALLING_STATION_ID, callingStationId ], [ attributes.CALLED_STATION_ID, calledStationId ], ], }); }; /** * Redis server ping * @param {string} dsn The redis connection string */ exports.redisPingAsync = function (dsn) { return new Promise((resolve, reject) => { const client = redis.createClient({ url: dsn, }); client.on("error", (err) => { reject(err); }); client.connect().then(() => { client.ping().then((res, err) => { if (err) { reject(err); } else { resolve(res); } }); }); }); }; /** * Retrieve value of setting based on key * @param {string} key Key of setting to retrieve * @returns {Promise} Value * @deprecated Use await Settings.get(key) */ exports.setting = async function (key) { return await Settings.get(key); }; /** * Sets the specified setting to specifed value * @param {string} key Key of setting to set * @param {any} value Value to set to * @param {?string} type Type of setting * @returns {Promise} */ exports.setSetting = async function (key, value, type = null) { await Settings.set(key, value, type); }; /** * Get settings based on type * @param {string} type The type of setting * @returns {Promise} */ exports.getSettings = async function (type) { return await Settings.getSettings(type); }; /** * Set settings based on type * @param {string} type Type of settings to set * @param {Object} data Values of settings * @returns {Promise} */ exports.setSettings = async function (type, data) { await Settings.setSettings(type, data); }; // ssl-checker by @dyaa //https://github.com/dyaa/ssl-checker/blob/master/src/index.ts /** * Get number of days between two dates * @param {Date} validFrom Start date * @param {Date} validTo End date * @returns {number} */ const getDaysBetween = (validFrom, validTo) => Math.round(Math.abs(+validFrom - +validTo) / 8.64e7); /** * Get days remaining from a time range * @param {Date} validFrom Start date * @param {Date} validTo End date * @returns {number} */ const getDaysRemaining = (validFrom, validTo) => { const daysRemaining = getDaysBetween(validFrom, validTo); if (new Date(validTo).getTime() < new Date().getTime()) { return -daysRemaining; } return daysRemaining; }; /** * Fix certificate info for display * @param {Object} info The chain obtained from getPeerCertificate() * @returns {Object} An object representing certificate information */ const parseCertificateInfo = function (info) { let link = info; let i = 0; const existingList = {}; while (link) { log.debug("cert", `[${i}] ${link.fingerprint}`); if (!link.valid_from || !link.valid_to) { break; } link.validTo = new Date(link.valid_to); link.validFor = link.subjectaltname?.replace(/DNS:|IP Address:/g, "").split(", "); link.daysRemaining = getDaysRemaining(new Date(), link.validTo); existingList[link.fingerprint] = true; // Move up the chain until loop is encountered if (link.issuerCertificate == null) { break; } else if (link.issuerCertificate.fingerprint in existingList) { log.debug("cert", `[Last] ${link.issuerCertificate.fingerprint}`); link.issuerCertificate = null; break; } else { link = link.issuerCertificate; } // Should be no use, but just in case. if (i > 500) { throw new Error("Dead loop occurred in parseCertificateInfo"); } i++; } return info; }; /** * Check if certificate is valid * @param {Object} res Response object from axios * @returns {Object} Object containing certificate information */ exports.checkCertificate = function (res) { if (!res.request.res.socket) { throw new Error("No socket found"); } const info = res.request.res.socket.getPeerCertificate(true); const valid = res.request.res.socket.authorized || false; log.debug("cert", "Parsing Certificate Info"); const parsedInfo = parseCertificateInfo(info); return { valid: valid, certInfo: parsedInfo }; }; /** * Check if the provided status code is within the accepted ranges * @param {number} status The status code to check * @param {string[]} acceptedCodes An array of accepted status codes * @returns {boolean} True if status code within range, false otherwise * @throws {Error} Will throw an error if the provided status code is not a valid range string or code string */ exports.checkStatusCode = function (status, acceptedCodes) { if (acceptedCodes == null || acceptedCodes.length === 0) { return false; } for (const codeRange of acceptedCodes) { const codeRangeSplit = codeRange.split("-").map(string => parseInt(string)); if (codeRangeSplit.length === 1) { if (status === codeRangeSplit[0]) { return true; } } else if (codeRangeSplit.length === 2) { if (status >= codeRangeSplit[0] && status <= codeRangeSplit[1]) { return true; } } else { throw new Error("Invalid status code range"); } } return false; }; /** * Get total number of clients in room * @param {Server} io Socket server instance * @param {string} roomName Name of room to check * @returns {number} */ exports.getTotalClientInRoom = (io, roomName) => { const sockets = io.sockets; if (!sockets) { return 0; } const adapter = sockets.adapter; if (!adapter) { return 0; } const room = adapter.rooms.get(roomName); if (room) { return room.size; } else { return 0; } }; /** * Allow CORS all origins if development * @param {Object} res Response object from axios */ exports.allowDevAllOrigin = (res) => { if (process.env.NODE_ENV === "development") { exports.allowAllOrigin(res); } }; /** * Allow CORS all origins * @param {Object} res Response object from axios */ exports.allowAllOrigin = (res) => { res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); }; /** * Check if a user is logged in * @param {Socket} socket Socket instance */ exports.checkLogin = (socket) => { if (!socket.userID) { throw new Error("You are not logged in."); } }; /** * For logged-in users, double-check the password * @param {Socket} socket Socket.io instance * @param {string} currentPassword * @returns {Promise} */ exports.doubleCheckPassword = async (socket, currentPassword) => { if (typeof currentPassword !== "string") { throw new Error("Wrong data type?"); } let user = await R.findOne("user", " id = ? AND active = 1 ", [ socket.userID, ]); if (!user || !passwordHash.verify(currentPassword, user.password)) { throw new Error("Incorrect current password"); } return user; }; /** Start Unit tests */ exports.startUnitTest = async () => { console.log("Starting unit test..."); const npm = /^win/.test(process.platform) ? "npm.cmd" : "npm"; const child = childProcess.spawn(npm, [ "run", "jest-backend" ]); child.stdout.on("data", (data) => { console.log(data.toString()); }); child.stderr.on("data", (data) => { console.log(data.toString()); }); child.on("close", function (code) { console.log("Jest exit code: " + code); process.exit(code); }); }; /** Start end-to-end tests */ exports.startE2eTests = async () => { console.log("Starting unit test..."); const npm = /^win/.test(process.platform) ? "npm.cmd" : "npm"; const child = childProcess.spawn(npm, [ "run", "cy:run" ]); child.stdout.on("data", (data) => { console.log(data.toString()); }); child.stderr.on("data", (data) => { console.log(data.toString()); }); child.on("close", function (code) { console.log("Jest exit code: " + code); process.exit(code); }); }; /** * Convert unknown string to UTF8 * @param {Uint8Array} body Buffer * @returns {string} */ exports.convertToUTF8 = (body) => { const guessEncoding = chardet.detect(body); const str = iconv.decode(body, guessEncoding); return str.toString(); }; /** * Returns a color code in hex format based on a given percentage: * 0% => hue = 10 => red * 100% => hue = 90 => green * * @param {number} percentage float, 0 to 1 * @param {number} maxHue * @param {number} minHue, int * @returns {string}, hex value */ exports.percentageToColor = (percentage, maxHue = 90, minHue = 10) => { const hue = percentage * (maxHue - minHue) + minHue; try { return chroma(`hsl(${hue}, 90%, 40%)`).hex(); } catch (err) { return badgeConstants.naColor; } }; /** * Joins and array of string to one string after filtering out empty values * * @param {string[]} parts * @param {string} connector * @returns {string} */ exports.filterAndJoin = (parts, connector = "") => { return parts.filter((part) => !!part && part !== "").join(connector); }; /** * Send a 403 response * @param {Object} res Express response object * @param {string} [msg=""] Message to send */ module.exports.send403 = (res, msg = "") => { res.status(403).json({ "status": "fail", "msg": msg, }); }; function timeObjectConvertTimezone(obj, timezone, timeObjectToUTC = true) { let offsetString; if (timezone) { offsetString = dayjs().tz(timezone).format("Z"); } else { offsetString = dayjs().format("Z"); } let hours = parseInt(offsetString.substring(1, 3)); let minutes = parseInt(offsetString.substring(4, 6)); if ( (timeObjectToUTC && offsetString.startsWith("+")) || (!timeObjectToUTC && offsetString.startsWith("-")) ) { hours *= -1; minutes *= -1; } obj.hours += hours; obj.minutes += minutes; // Handle out of bound if (obj.minutes < 0) { obj.minutes += 60; obj.hours--; } else if (obj.minutes > 60) { obj.minutes -= 60; obj.hours++; } if (obj.hours < 0) { obj.hours += 24; } else if (obj.hours > 24) { obj.hours -= 24; } return obj; } /** * * @param {object} obj * @param {string} timezone * @returns {object} */ module.exports.timeObjectToUTC = (obj, timezone = undefined) => { return timeObjectConvertTimezone(obj, timezone, true); }; /** * * @param {object} obj * @param {string} timezone * @returns {object} */ module.exports.timeObjectToLocal = (obj, timezone = undefined) => { return timeObjectConvertTimezone(obj, timezone, false); }; /** * Create gRPC client stib * @param {Object} options from gRPC client */ module.exports.grpcQuery = async (options) => { const { grpcUrl, grpcProtobufData, grpcServiceName, grpcEnableTls, grpcMethod, grpcBody } = options; const protocObject = protojs.parse(grpcProtobufData); const protoServiceObject = protocObject.root.lookupService(grpcServiceName); const Client = grpc.makeGenericClientConstructor({}); const credentials = grpcEnableTls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure(); const client = new Client( grpcUrl, credentials ); const grpcService = protoServiceObject.create(function (method, requestData, cb) { const fullServiceName = method.fullName; const serviceFQDN = fullServiceName.split("."); const serviceMethod = serviceFQDN.pop(); const serviceMethodClientImpl = `/${serviceFQDN.slice(1).join(".")}/${serviceMethod}`; log.debug("monitor", `gRPC method ${serviceMethodClientImpl}`); client.makeUnaryRequest( serviceMethodClientImpl, arg => arg, arg => arg, requestData, cb); }, false, false); return new Promise((resolve, _) => { try { return grpcService[`${grpcMethod}`](JSON.parse(grpcBody), function (err, response) { const responseData = JSON.stringify(response); if (err) { return resolve({ code: err.code, errorMessage: err.details, data: "" }); } else { log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`); return resolve({ code: 1, errorMessage: "", data: responseData }); } }); } catch (err) { return resolve({ code: -1, errorMessage: `Error ${err}. Please review your gRPC configuration option. The service name must not include package name value, and the method name must follow camelCase format`, data: "" }); } }); };