const tcpp = require("tcp-ping"); const ping = require("@louislam/ping"); const { R } = require("redbean-node"); const { log, genSecret, badgeConstants } = require("../src/util"); const passwordHash = require("./password-hash"); const { Resolver } = require("dns"); const iconv = require("iconv-lite"); const chardet = require("chardet"); const chroma = require("chroma-js"); 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 radiusClient = require("node-radius-client"); const redis = require("redis"); const oidc = require("openid-client"); const tls = require("tls"); const { dictionaries: { rfc2865: { file, attributes }, }, } = require("node-radius-utils"); const dayjs = require("dayjs"); // SASLOptions used in JSDoc // eslint-disable-next-line no-unused-vars const { Kafka, SASLOptions } = require("kafkajs"); const crypto = require("crypto"); const isWindows = process.platform === /^win/.test(process.platform); /** * Init or reset JWT secret * @returns {Promise} JWT secret */ 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; }; /** * Decodes a jwt and returns the payload portion without verifying the jqt. * @param {string} jwt The input jwt as a string * @returns {object} Decoded jwt payload object */ exports.decodeJwt = (jwt) => { return JSON.parse(Buffer.from(jwt.split(".")[1], "base64").toString()); }; /** * Gets a Access Token form a oidc/oauth2 provider * @param {string} tokenEndpoint The token URI form the auth service provider * @param {string} clientId The oidc/oauth application client id * @param {string} clientSecret The oidc/oauth application client secret * @param {string} scope The scope the for which the token should be issued for * @param {string} authMethod The method on how to sent the credentials. Default client_secret_basic * @returns {Promise} TokenSet promise if the token request was successful */ exports.getOidcTokenClientCredentials = async (tokenEndpoint, clientId, clientSecret, scope, authMethod = "client_secret_basic") => { const oauthProvider = new oidc.Issuer({ token_endpoint: tokenEndpoint }); let client = new oauthProvider.Client({ client_id: clientId, client_secret: clientSecret, token_endpoint_auth_method: authMethod }); // Increase default timeout and clock tolerance client[oidc.custom.http_options] = () => ({ timeout: 10000 }); client[oidc.custom.clock_tolerance] = 5; let grantParams = { grant_type: "client_credentials" }; if (scope) { grantParams.scope = scope; } return await client.grant(grantParams); }; /** * 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 * @param {number} size Size of packet to send * @returns {Promise} Time for ping in ms rounded to nearest integer */ exports.ping = async (hostname, size = 56) => { try { return await exports.pingAsync(hostname, false, size); } catch (e) { // If the host cannot be resolved, try again with ipv6 log.debug("ping", "IPv6 error message: " + e.message); // As node-ping does not report a specific error for this, try again if it is an empty message with ipv6 no matter what. if (!e.message) { return await exports.pingAsync(hostname, true, size); } else { throw e; } } }; /** * Ping the specified machine * @param {string} hostname Hostname / address of machine to ping * @param {boolean} ipv6 Should IPv6 be used? * @param {number} size Size of ping packet to send * @returns {Promise} Time for ping in ms rounded to nearest integer */ exports.pingAsync = function (hostname, ipv6 = false, size = 56) { return new Promise((resolve, reject) => { ping.promise.probe(hostname, { v6: ipv6, min_reply: 1, deadline: 10, packetSize: size, }).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); }); }); }; /** * Monitor Kafka using Producer * @param {string[]} brokers List of kafka brokers to connect, host and * port joined by ':' * @param {string} topic Topic name to produce into * @param {string} message Message to produce * @param {object} options Kafka client options. Contains ssl, clientId, * allowAutoTopicCreation and interval (interval defaults to 20, * allowAutoTopicCreation defaults to false, clientId defaults to * "Uptime-Kuma" and ssl defaults to false) * @param {SASLOptions} saslOptions Options for kafka client * Authentication (SASL) (defaults to {}) * @returns {Promise} Status message */ exports.kafkaProducerAsync = function (brokers, topic, message, options = {}, saslOptions = {}) { return new Promise((resolve, reject) => { const { interval = 20, allowAutoTopicCreation = false, ssl = false, clientId = "Uptime-Kuma" } = options; let connectedToKafka = false; const timeoutID = setTimeout(() => { log.debug("kafkaProducer", "KafkaProducer timeout triggered"); connectedToKafka = true; reject(new Error("Timeout")); }, interval * 1000 * 0.8); if (saslOptions.mechanism === "None") { saslOptions = undefined; } let client = new Kafka({ brokers: brokers, clientId: clientId, sasl: saslOptions, retry: { retries: 0, }, ssl: ssl, }); let producer = client.producer({ allowAutoTopicCreation: allowAutoTopicCreation, retry: { retries: 0, } }); producer.connect().then( () => { producer.send({ topic: topic, messages: [{ value: message, }], }).then((_) => { resolve("Message sent successfully"); }).catch((e) => { connectedToKafka = true; producer.disconnect(); clearTimeout(timeoutID); reject(new Error("Error sending message: " + e.message)); }).finally(() => { connectedToKafka = true; clearTimeout(timeoutID); }); } ).catch( (e) => { connectedToKafka = true; producer.disconnect(); clearTimeout(timeoutID); reject(new Error("Error in producer connection: " + e.message)); } ); producer.on("producer.network.request_timeout", (_) => { if (!connectedToKafka) { clearTimeout(timeoutID); reject(new Error("producer.network.request_timeout")); } }); producer.on("producer.disconnect", (_) => { if (!connectedToKafka) { clearTimeout(timeoutID); reject(new Error("producer.disconnect")); } }); }); }; /** * 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)>} NTLM response */ 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)>} DNS response */ 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)>} Response from * server */ exports.mssqlQuery = async function (connectionString, query) { let pool; try { pool = new mssql.ConnectionPool(connectionString); await pool.connect(); if (!query) { query = "SELECT 1"; } 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)>} Response from * server */ exports.postgresQuery = function (connectionString, query) { return new Promise((resolve, reject) => { const config = postgresConParse(connectionString); // Fix #3868, which true/false is not parsed to boolean if (typeof config.ssl === "string") { config.ssl = config.ssl === "true"; } if (config.password === "") { // See https://github.com/brianc/node-postgres/issues/1927 reject(new Error("Password is undefined.")); return; } const client = new Client(config); client.on("error", (error) => { log.debug("postgres", "Error caught in the error event handler."); reject(error); }); client.connect((err) => { if (err) { reject(err); client.end(); } else { // Connected here try { // No query provided by user, use SELECT 1 if (!query || (typeof query === "string" && query.trim() === "")) { query = "SELECT 1"; } client.query(query, (err, res) => { if (err) { reject(err); } else { resolve(res); } client.end(); }); } catch (e) { reject(e); 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 * @param {?string} password The password to use * @returns {Promise<(string)>} Response from server */ exports.mysqlQuery = function (connectionString, query, password = undefined) { return new Promise((resolve, reject) => { const connection = mysql.createConnection({ uri: connectionString, password }); connection.on("error", (err) => { reject(err); }); connection.query(query, (err, res) => { if (err) { reject(err); } else { if (Array.isArray(res)) { resolve("Rows: " + res.length); } else { resolve("No Error, but the result is not an array. Type: " + typeof res); } } try { connection.end(); } catch (_) { connection.destroy(); } }); }); }; /** * 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 Port to contact radius server on * @param {number} timeout Timeout for connection to use * @returns {Promise} Response from server */ exports.radius = function ( hostname, username, password, calledStationId, callingStationId, secret, port = 1812, timeout = 2500, ) { const client = new radiusClient({ host: hostname, hostPort: port, timeout: timeout, retries: 1, 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 ], ], }).catch((error) => { if (error.response?.code) { throw Error(error.response.code); } else { throw Error(error.message); } }); }; /** * Redis server ping * @param {string} dsn The redis connection string * @param {boolean} rejectUnauthorized If false, allows unverified server certificates. * @returns {Promise} Response from server */ exports.redisPingAsync = function (dsn, rejectUnauthorized) { return new Promise((resolve, reject) => { const client = redis.createClient({ url: dsn, socket: { rejectUnauthorized } }); client.on("error", (err) => { if (client.isOpen) { client.disconnect(); } reject(err); }); client.connect().then(() => { if (!client.isOpen) { client.emit("error", new Error("connection isn't open")); } client.ping().then((res, err) => { if (client.isOpen) { client.disconnect(); } if (err) { reject(err); } else { resolve(res); } }).catch(error => reject(error)); }); }); }; /** * 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 specified 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} Settings of requested type */ 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} Number of days */ 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} Number of days remaining */ 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 * @throws The certificate chain length exceeded 500. */ 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) { link.certType = (i === 0) ? "self-signed" : "root CA"; break; } else if (link.issuerCertificate.fingerprint in existingList) { // a root CA certificate is typically "signed by itself" (=> "self signed certificate") and thus the "issuerCertificate" is a reference to itself. log.debug("cert", `[Last] ${link.issuerCertificate.fingerprint}`); link.certType = (i === 0) ? "self-signed" : "root CA"; link.issuerCertificate = null; break; } else { link.certType = (i === 0) ? "server" : "intermediate CA"; 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 {tls.TLSSocket} socket TLSSocket, which may or may not be connected * @returns {object} Object containing certificate information */ exports.checkCertificate = function (socket) { let certInfoStartTime = dayjs().valueOf(); // Return null if there is no socket if (socket === undefined || socket == null) { return null; } const info = socket.getPeerCertificate(true); const valid = socket.authorized || false; log.debug("cert", "Parsing Certificate Info"); const parsedInfo = parseCertificateInfo(info); if (process.env.TIMELOGGER === "1") { log.debug("monitor", "Cert Info Query Time: " + (dayjs().valueOf() - certInfoStartTime) + "ms"); } 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 */ exports.checkStatusCode = function (status, acceptedCodes) { if (acceptedCodes == null || acceptedCodes.length === 0) { return false; } for (const codeRange of acceptedCodes) { if (typeof codeRange !== "string") { log.error("monitor", `Accepted status code not a string. ${codeRange} is of type ${typeof codeRange}`); continue; } 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 { log.error("monitor", `${codeRange} is not a valid status code range`); continue; } } 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} Total clients in room */ 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 * @returns {void} */ exports.allowDevAllOrigin = (res) => { if (process.env.NODE_ENV === "development") { exports.allowAllOrigin(res); } }; /** * Allow CORS all origins * @param {object} res Response object from axios * @returns {void} */ exports.allowAllOrigin = (res) => { res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS"); 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 * @returns {void} * @throws The user is not logged in */ 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 Password to validate * @returns {Promise} User * @throws The current password is not a string * @throws The provided password is not correct */ 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; }; /** * Convert unknown string to UTF8 * @param {Uint8Array} body Buffer * @returns {string} UTF8 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 Maximum hue - int * @param {number} minHue Minimum hue - int * @returns {string} Color in hex */ 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 Strings to join * @param {string} connector Separator for joined strings * @returns {string} Joined strings */ exports.filterAndJoin = (parts, connector = "") => { return parts.filter((part) => !!part && part !== "").join(connector); }; /** * Send an Error response * @param {object} res Express response object * @param {string} msg Message to send * @returns {void} */ module.exports.sendHttpError = (res, msg = "") => { if (msg.includes("SQLITE_BUSY") || msg.includes("SQLITE_LOCKED")) { res.status(503).json({ "status": "fail", "msg": msg, }); } else if (msg.toLowerCase().includes("not found")) { res.status(404).json({ "status": "fail", "msg": msg, }); } else { res.status(403).json({ "status": "fail", "msg": msg, }); } }; /** * Convert timezone of time object * @param {object} obj Time object to update * @param {string} timezone New timezone to set * @param {boolean} timeObjectToUTC Convert time object to UTC * @returns {object} Time object with updated timezone */ 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; } /** * Convert time object to UTC * @param {object} obj Object to convert * @param {string} timezone Timezone of time object * @returns {object} Updated time object */ module.exports.timeObjectToUTC = (obj, timezone = undefined) => { return timeObjectConvertTimezone(obj, timezone, true); }; /** * Convert time object to local time * @param {object} obj Object to convert * @param {string} timezone Timezone to convert to * @returns {object} Updated object */ module.exports.timeObjectToLocal = (obj, timezone = undefined) => { return timeObjectConvertTimezone(obj, timezone, false); }; /** * Returns an array of SHA256 fingerprints for all known root certificates. * @returns {Set} A set of SHA256 fingerprints. */ module.exports.rootCertificatesFingerprints = () => { let fingerprints = tls.rootCertificates.map(cert => { let certLines = cert.split("\n"); certLines.shift(); certLines.pop(); let certBody = certLines.join(""); let buf = Buffer.from(certBody, "base64"); const shasum = crypto.createHash("sha256"); shasum.update(buf); return shasum.digest("hex").toUpperCase().replace(/(.{2})(?!$)/g, "$1:"); }); fingerprints.push("6D:99:FB:26:5E:B1:C5:B3:74:47:65:FC:BC:64:8F:3C:D8:E1:BF:FA:FD:C4:C2:F9:9B:9D:47:CF:7F:F1:C2:4F"); // ISRG X1 cross-signed with DST X3 fingerprints.push("8B:05:B6:8C:C6:59:E5:ED:0F:CB:38:F2:C9:42:FB:FD:20:0E:6F:2F:F9:F8:5D:63:C6:99:4E:F5:E0:B0:27:01"); // ISRG X2 cross-signed with ISRG X1 return new Set(fingerprints); }; module.exports.SHAKE256_LENGTH = 16; /** * @param {string} data The data to be hashed * @param {number} len Output length of the hash * @returns {string} The hashed data in hex format */ module.exports.shake256 = (data, len) => { if (!data) { return ""; } return crypto.createHash("shake256", { outputLength: len }) .update(data) .digest("hex"); }; /** * Non await sleep * Source: https://stackoverflow.com/questions/59099454/is-there-a-way-to-call-sleep-without-await-keyword * @param {number} n Milliseconds to wait * @returns {void} */ module.exports.wait = (n) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, n); }; // For unit test, export functions if (process.env.TEST_BACKEND) { module.exports.__test = { parseCertificateInfo, }; module.exports.__getPrivateFunction = (functionName) => { return module.exports.__test[functionName]; }; } /** * Generates an abort signal with the specified timeout. * @param {number} timeoutMs - The timeout in milliseconds. * @returns {AbortSignal | null} - The generated abort signal, or null if not supported. */ module.exports.axiosAbortSignal = (timeoutMs) => { try { // Just in case, as 0 timeout here will cause the request to be aborted immediately if (!timeoutMs || timeoutMs <= 0) { timeoutMs = 5000; } return AbortSignal.timeout(timeoutMs); } catch (_) { // v16-: AbortSignal.timeout is not supported try { const abortController = new AbortController(); setTimeout(() => abortController.abort(), timeoutMs); return abortController.signal; } catch (_) { // v15-: AbortController is not supported return null; } } };