uptime-kuma/server/util-server.js

1154 lines
34 KiB
JavaScript
Raw Normal View History

2021-07-27 13:47:13 -04:00
const tcpp = require("tcp-ping");
const ping = require("@louislam/ping");
2021-07-27 13:47:13 -04:00
const { R } = require("redbean-node");
const { log, genSecret } = require("../src/util");
2021-08-09 01:34:44 -04:00
const passwordHash = require("./password-hash");
2021-08-23 10:30:11 -04:00
const { Resolver } = require("dns");
2022-04-13 12:30:32 -04:00
const childProcess = require("child_process");
const iconv = require("iconv-lite");
const chardet = require("chardet");
2021-11-03 21:46:43 -04:00
const mqtt = require("mqtt");
2022-01-03 09:48:52 -05:00
const chroma = require("chroma-js");
2022-01-04 06:21:53 -05:00
const { badgeConstants } = require("./config");
2022-05-13 09:40:46 -04:00
const mssql = require("mssql");
2022-06-15 13:12:47 -04:00
const { Client } = require("pg");
2022-06-15 14:00:14 -04:00
const postgresConParse = require("pg-connection-string").parse;
const mysql = require("mysql2");
2022-11-16 20:50:34 -05:00
const { MongoClient } = require("mongodb");
2022-05-13 13:58:23 -04:00
const { NtlmClient } = require("axios-ntlm");
const { Settings } = require("./settings");
const grpc = require("@grpc/grpc-js");
const protojs = require("protobufjs");
2022-05-12 05:48:38 -04:00
const radiusClient = require("node-radius-client");
2023-01-05 09:58:24 -05:00
const redis = require("redis");
const oidc = require("openid-client");
const tls = require("tls");
2022-05-12 05:48:38 -04:00
const {
dictionaries: {
rfc2865: { file, attributes },
},
} = require("node-radius-utils");
const dayjs = require("dayjs");
2021-08-09 01:34:44 -04:00
✨ feat: added kafka producer (#3268) * ✨ feat: added kafka producer Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: eslint warn Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: typings and auth problems Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: better variable name to trrack disconnection Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: grouping Kafka Producer special settings into one template Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * ✨ feat: add kafka producer translations into `en.json` Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: disable close-on-select on kafka broker picker Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: `en.json` invalid json (conflict resolve) Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * Nostr dm notifications (#3051) * Add nostr DM notification provider * require crypto for node 18 compatibility * remove whitespace Co-authored-by: Frank Elsinga <frank@elsinga.de> * move closer to where it is used * simplify success or failure logic * don't clobber the non-alert msg * Update server/notification-providers/nostr.js Co-authored-by: Frank Elsinga <frank@elsinga.de> * polyfills required for node <= 18 * resolve linter warnings * missing comma --------- Co-authored-by: Frank Elsinga <frank@elsinga.de> * Drop nostr * Minor * Fix a bug of clone --------- Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> Co-authored-by: Frank Elsinga <frank@elsinga.de> Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-07-17 04:15:44 -04:00
// SASLOptions used in JSDoc
// eslint-disable-next-line no-unused-vars
const { Kafka, SASLOptions } = require("kafkajs");
const crypto = require("crypto");
2023-01-05 06:22:15 -05:00
✨ feat: added kafka producer (#3268) * ✨ feat: added kafka producer Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: eslint warn Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: typings and auth problems Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: better variable name to trrack disconnection Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: grouping Kafka Producer special settings into one template Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * ✨ feat: add kafka producer translations into `en.json` Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: disable close-on-select on kafka broker picker Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: `en.json` invalid json (conflict resolve) Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * Nostr dm notifications (#3051) * Add nostr DM notification provider * require crypto for node 18 compatibility * remove whitespace Co-authored-by: Frank Elsinga <frank@elsinga.de> * move closer to where it is used * simplify success or failure logic * don't clobber the non-alert msg * Update server/notification-providers/nostr.js Co-authored-by: Frank Elsinga <frank@elsinga.de> * polyfills required for node <= 18 * resolve linter warnings * missing comma --------- Co-authored-by: Frank Elsinga <frank@elsinga.de> * Drop nostr * Minor * Fix a bug of clone --------- Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> Co-authored-by: Frank Elsinga <frank@elsinga.de> Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-07-17 04:15:44 -04:00
const isWindows = process.platform === /^win/.test(process.platform);
2021-08-09 01:34:44 -04:00
/**
* Init or reset JWT secret
* @returns {Promise<Bean>}
*/
exports.initJWTSecret = async () => {
let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
"jwtSecret",
]);
2021-11-03 21:46:43 -04:00
if (!jwtSecretBean) {
2021-08-09 01:34:44 -04:00
jwtSecretBean = R.dispense("setting");
jwtSecretBean.key = "jwtSecret";
}
2022-03-29 05:38:48 -04:00
jwtSecretBean.value = passwordHash.generate(genSecret());
2021-08-09 01:34:44 -04:00
await R.store(jwtSecretBean);
return jwtSecretBean;
2021-09-20 04:22:18 -04:00
};
2021-07-01 02:03:06 -04:00
/**
* 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<oidc.TokenSet>} 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<number>} Maximum time in ms rounded to nearest integer
*/
2021-07-01 02:03:06 -04:00
exports.tcping = function (hostname, port) {
return new Promise((resolve, reject) => {
tcpp.ping({
address: hostname,
port: port,
attempts: 1,
}, function (err, data) {
2021-07-01 02:03:06 -04:00
if (err) {
reject(err);
}
if (data.results.length >= 1 && data.results[0].err) {
reject(data.results[0].err);
}
resolve(Math.round(data.max));
});
});
2021-09-20 04:22:18 -04:00
};
2021-07-01 05:00:23 -04:00
/**
* Ping the specified machine
* @param {string} hostname Hostname / address of machine
* @param {number} [size=56] Size of packet to send
* @returns {Promise<number>} 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
2023-03-04 07:29:52 -05:00
console.debug("ping", "IPv6 error message: " + e.message);
2023-03-04 07:41:08 -05:00
// As node-ping does not report a specific error for this, try again if it is an empty message with ipv6 no matter what.
2023-03-04 07:29:52 -05:00
if (!e.message) {
return await exports.pingAsync(hostname, true, size);
} else {
throw e;
}
}
2021-09-20 04:22:18 -04:00
};
/**
* Ping the specified machine
* @param {string} hostname Hostname / address of machine to ping
* @param {boolean} ipv6 Should IPv6 be used?
* @param {number} [size = 56] Size of ping packet to send
* @returns {Promise<number>} Time for ping in ms rounded to nearest integer
*/
exports.pingAsync = function (hostname, ipv6 = false, size = 56) {
2021-07-01 05:00:23 -04:00
return new Promise((resolve, reject) => {
ping.promise.probe(hostname, {
v6: ipv6,
2023-01-05 06:30:55 -05:00
min_reply: 1,
2023-01-16 12:21:01 -05:00
deadline: 10,
packetSize: size,
}).then((res) => {
// If ping failed, it will set field to unknown
if (res.alive) {
resolve(res.time);
} else {
2023-01-05 06:22:15 -05:00
if (isWindows) {
reject(new Error(exports.convertToUTF8(res.output)));
} else {
reject(new Error(res.output));
}
2021-07-01 05:00:23 -04:00
}
}).catch((err) => {
reject(err);
2021-07-01 05:00:23 -04:00
});
});
2021-09-20 04:22:18 -04:00
};
2021-07-09 02:14:03 -04:00
/**
* 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<string>}
*/
exports.mqttAsync = function (hostname, topic, okMessage, options = {}) {
2021-11-03 21:46:43 -04:00
return new Promise((resolve, reject) => {
const { port, username, password, interval = 20 } = options;
2022-01-12 23:42:34 -05:00
// Adds MQTT protocol to the hostname if not already present
if (!/^(?:http|mqtt|ws)s?:\/\//.test(hostname)) {
2022-01-12 23:42:34 -05:00
hostname = "mqtt://" + hostname;
2021-11-03 21:46:43 -04:00
}
2022-01-12 23:42:34 -05:00
const timeoutID = setTimeout(() => {
2022-04-16 01:37:17 -04:00
log.debug("mqtt", "MQTT timeout triggered");
client.end();
reject(new Error("Timeout"));
}, interval * 1000 * 0.8);
2023-01-05 05:42:19 -05:00
const mqttUrl = `${hostname}:${port}`;
2022-01-12 23:42:34 -05:00
log.debug("mqtt", `MQTT connecting to ${mqttUrl}`);
2022-01-12 23:42:34 -05:00
let client = mqtt.connect(mqttUrl, {
2022-01-12 23:42:34 -05:00
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"));
}
2022-01-12 23:42:34 -05:00
});
client.on("error", (error) => {
client.end();
clearTimeout(timeoutID);
2022-01-12 23:42:34 -05:00
reject(error);
});
client.on("message", (messageTopic, message) => {
2022-04-25 19:26:57 -04:00
if (messageTopic === topic) {
client.end();
clearTimeout(timeoutID);
if (okMessage != null && okMessage !== "" && message.toString() !== okMessage) {
reject(new Error(`Message Mismatch - Topic: ${messageTopic}; Message: ${message.toString()}`));
2022-01-12 23:42:34 -05:00
} else {
resolve(`Topic: ${messageTopic}; Message: ${message.toString()}`);
2022-01-12 23:42:34 -05:00
}
}
});
2021-11-03 21:46:43 -04:00
});
2022-05-13 13:58:23 -04:00
};
✨ feat: added kafka producer (#3268) * ✨ feat: added kafka producer Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: eslint warn Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: typings and auth problems Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: better variable name to trrack disconnection Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: grouping Kafka Producer special settings into one template Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * ✨ feat: add kafka producer translations into `en.json` Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: disable close-on-select on kafka broker picker Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: `en.json` invalid json (conflict resolve) Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * Nostr dm notifications (#3051) * Add nostr DM notification provider * require crypto for node 18 compatibility * remove whitespace Co-authored-by: Frank Elsinga <frank@elsinga.de> * move closer to where it is used * simplify success or failure logic * don't clobber the non-alert msg * Update server/notification-providers/nostr.js Co-authored-by: Frank Elsinga <frank@elsinga.de> * polyfills required for node <= 18 * resolve linter warnings * missing comma --------- Co-authored-by: Frank Elsinga <frank@elsinga.de> * Drop nostr * Minor * Fix a bug of clone --------- Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> Co-authored-by: Frank Elsinga <frank@elsinga.de> Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-07-17 04:15:44 -04:00
/**
* Monitor Kafka using Producer
* @param {string} topic Topic name to produce into
* @param {string} message Message to produce
* @param {Object} [options={interval = 20, allowAutoTopicCreation = false, ssl = false, clientId = "Uptime-Kuma"}]
* 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 {string[]} brokers List of kafka brokers to connect, host and port joined by ':'
* @param {SASLOptions} [saslOptions={}] Options for kafka client Authentication (SASL) (defaults to
* {})
* @returns {Promise<string>}
*/
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((_) => {
✨ feat: added kafka producer (#3268) * ✨ feat: added kafka producer Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: eslint warn Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: typings and auth problems Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: better variable name to trrack disconnection Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: grouping Kafka Producer special settings into one template Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * ✨ feat: add kafka producer translations into `en.json` Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: disable close-on-select on kafka broker picker Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: `en.json` invalid json (conflict resolve) Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * Nostr dm notifications (#3051) * Add nostr DM notification provider * require crypto for node 18 compatibility * remove whitespace Co-authored-by: Frank Elsinga <frank@elsinga.de> * move closer to where it is used * simplify success or failure logic * don't clobber the non-alert msg * Update server/notification-providers/nostr.js Co-authored-by: Frank Elsinga <frank@elsinga.de> * polyfills required for node <= 18 * resolve linter warnings * missing comma --------- Co-authored-by: Frank Elsinga <frank@elsinga.de> * Drop nostr * Minor * Fix a bug of clone --------- Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> Co-authored-by: Frank Elsinga <frank@elsinga.de> Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-07-17 04:15:44 -04:00
resolve("Message sent successfully");
}).catch((e) => {
✨ feat: added kafka producer (#3268) * ✨ feat: added kafka producer Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: eslint warn Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: typings and auth problems Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: better variable name to trrack disconnection Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: grouping Kafka Producer special settings into one template Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * ✨ feat: add kafka producer translations into `en.json` Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: disable close-on-select on kafka broker picker Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: `en.json` invalid json (conflict resolve) Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * Nostr dm notifications (#3051) * Add nostr DM notification provider * require crypto for node 18 compatibility * remove whitespace Co-authored-by: Frank Elsinga <frank@elsinga.de> * move closer to where it is used * simplify success or failure logic * don't clobber the non-alert msg * Update server/notification-providers/nostr.js Co-authored-by: Frank Elsinga <frank@elsinga.de> * polyfills required for node <= 18 * resolve linter warnings * missing comma --------- Co-authored-by: Frank Elsinga <frank@elsinga.de> * Drop nostr * Minor * Fix a bug of clone --------- Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> Co-authored-by: Frank Elsinga <frank@elsinga.de> Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-07-17 04:15:44 -04:00
connectedToKafka = true;
producer.disconnect();
clearTimeout(timeoutID);
reject(new Error("Error sending message: " + e.message));
}).finally(() => {
connectedToKafka = true;
clearTimeout(timeoutID);
});
✨ feat: added kafka producer (#3268) * ✨ feat: added kafka producer Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: eslint warn Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: typings and auth problems Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: better variable name to trrack disconnection Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: grouping Kafka Producer special settings into one template Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * ✨ feat: add kafka producer translations into `en.json` Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: disable close-on-select on kafka broker picker Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: `en.json` invalid json (conflict resolve) Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * Nostr dm notifications (#3051) * Add nostr DM notification provider * require crypto for node 18 compatibility * remove whitespace Co-authored-by: Frank Elsinga <frank@elsinga.de> * move closer to where it is used * simplify success or failure logic * don't clobber the non-alert msg * Update server/notification-providers/nostr.js Co-authored-by: Frank Elsinga <frank@elsinga.de> * polyfills required for node <= 18 * resolve linter warnings * missing comma --------- Co-authored-by: Frank Elsinga <frank@elsinga.de> * Drop nostr * Minor * Fix a bug of clone --------- Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> Co-authored-by: Frank Elsinga <frank@elsinga.de> Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-07-17 04:15:44 -04:00
}
).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"));
}
✨ feat: added kafka producer (#3268) * ✨ feat: added kafka producer Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: eslint warn Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: typings and auth problems Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: better variable name to trrack disconnection Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: grouping Kafka Producer special settings into one template Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * ✨ feat: add kafka producer translations into `en.json` Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: disable close-on-select on kafka broker picker Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * 🐛 fix: `en.json` invalid json (conflict resolve) Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> * Nostr dm notifications (#3051) * Add nostr DM notification provider * require crypto for node 18 compatibility * remove whitespace Co-authored-by: Frank Elsinga <frank@elsinga.de> * move closer to where it is used * simplify success or failure logic * don't clobber the non-alert msg * Update server/notification-providers/nostr.js Co-authored-by: Frank Elsinga <frank@elsinga.de> * polyfills required for node <= 18 * resolve linter warnings * missing comma --------- Co-authored-by: Frank Elsinga <frank@elsinga.de> * Drop nostr * Minor * Fix a bug of clone --------- Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev> Co-authored-by: Frank Elsinga <frank@elsinga.de> Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-07-17 04:15:44 -04:00
});
producer.on("producer.disconnect", (_) => {
if (!connectedToKafka) {
clearTimeout(timeoutID);
reject(new Error("producer.disconnect"));
}
});
});
};
2022-05-13 13:58:23 -04:00
/**
* 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);
});
});
2021-11-03 21:46:43 -04:00
};
/**
* 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) {
2021-08-22 18:05:48 -04:00
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("]", "");
2022-06-01 01:05:12 -04:00
resolver.setServers([ `[${resolverServer}]:${resolverPort}` ]);
2021-08-22 18:05:48 -04:00
return new Promise((resolve, reject) => {
2022-04-17 03:43:03 -04:00
if (rrtype === "PTR") {
2021-08-22 18:05:48 -04:00
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);
}
});
}
2021-09-20 04:22:18 -04:00
});
};
2021-08-22 18:05:48 -04:00
2022-05-12 13:48:03 -04:00
/**
* 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)>}
*/
2023-01-01 09:27:14 -05:00
exports.mssqlQuery = async function (connectionString, query) {
let pool;
try {
pool = new mssql.ConnectionPool(connectionString);
await pool.connect();
if (!query) {
query = "SELECT 1";
}
2023-01-01 09:27:14 -05:00
await pool.request().query(query);
pool.close();
} catch (e) {
if (pool) {
pool.close();
}
throw e;
}
2022-05-12 13:48:03 -04:00
};
2022-06-15 13:12:47 -04:00
/**
* 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) => {
2022-06-15 14:00:14 -04:00
const config = postgresConParse(connectionString);
// Fix #3868, which true/false is not parsed to boolean
if (typeof config.ssl === "string") {
config.ssl = config.ssl === "true";
}
2022-06-15 14:00:14 -04:00
if (config.password === "") {
// See https://github.com/brianc/node-postgres/issues/1927
reject(new Error("Password is undefined."));
return;
2022-06-15 14:00:14 -04:00
}
const client = new Client(config);
2022-06-15 14:00:14 -04:00
client.on("error", (error) => {
log.debug("postgres", "Error caught in the error event handler.");
reject(error);
});
2022-06-15 13:12:47 -04:00
client.connect((err) => {
if (err) {
2022-06-15 13:12:47 -04:00
reject(err);
client.end();
} else {
// Connected here
2023-02-14 13:50:49 -05:00
try {
// No query provided by user, use SELECT 1
if (!query || (typeof query === "string" && query.trim() === "")) {
query = "SELECT 1";
}
2023-02-14 13:50:49 -05:00
client.query(query, (err, res) => {
if (err) {
reject(err);
} else {
resolve(res);
}
client.end();
});
} catch (e) {
reject(e);
client.end();
2023-02-14 13:50:49 -05:00
}
}
});
2022-06-15 13:12:47 -04:00
});
};
/**
* 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)>}
*/
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();
}
});
});
};
2022-11-16 20:50:34 -05:00
/**
* Connect to and Ping a MongoDB database
* @param {string} connectionString The database connection string
* @returns {Promise<(string[]|Object[]|Object)>}
*/
exports.mongodbPing = async function (connectionString) {
let client = await MongoClient.connect(connectionString);
let dbPing = await client.db().command({ ping: 1 });
await client.close();
if (dbPing["ok"] === 1) {
return "UP";
} else {
throw Error("failed");
2022-11-16 20:50:34 -05:00
}
};
2022-11-16 20:50:34 -05:00
/**
* 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
2023-05-23 06:18:54 -04:00
* @param {number} [timeout=2500] Timeout for connection to use
* @returns {Promise<any>}
*/
2022-05-12 05:48:38 -04:00
exports.radius = function (
hostname,
username,
password,
calledStationId,
callingStationId,
secret,
port = 1812,
2023-05-23 06:18:54 -04:00
timeout = 2500,
2022-05-12 05:48:38 -04:00
) {
const client = new radiusClient({
host: hostname,
hostPort: port,
2023-05-23 06:18:54 -04:00
timeout: timeout,
2023-07-27 05:42:22 -04:00
retries: 1,
2022-05-12 05:48:38 -04:00
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 ],
],
2023-07-27 05:42:22 -04:00
}).catch((error) => {
if (error.response?.code) {
throw Error(error.response.code);
} else {
throw Error(error.message);
}
2022-05-12 05:48:38 -04:00
});
};
2023-01-05 09:58:24 -05:00
/**
* Redis server ping
* @param {string} dsn The redis connection string
*/
2023-01-13 06:10:07 -05:00
exports.redisPingAsync = function (dsn) {
return new Promise((resolve, reject) => {
const client = redis.createClient({
url: dsn
2023-01-13 06:10:07 -05:00
});
client.on("error", (err) => {
if (client.isOpen) {
client.disconnect();
}
2023-01-13 06:10:07 -05:00
reject(err);
});
client.connect().then(() => {
if (!client.isOpen) {
client.emit("error", new Error("connection isn't open"));
2023-06-09 16:26:02 -04:00
}
client.ping().then((res, err) => {
if (client.isOpen) {
client.disconnect();
}
if (err) {
reject(err);
} else {
resolve(res);
}
}).catch(error => reject(error));
2023-01-13 06:10:07 -05:00
});
2023-01-05 09:58:24 -05:00
});
};
2022-05-12 05:48:38 -04:00
/**
* Retrieve value of setting based on key
* @param {string} key Key of setting to retrieve
* @returns {Promise<any>} Value
2022-07-31 11:41:29 -04:00
* @deprecated Use await Settings.get(key)
*/
2021-07-09 02:14:03 -04:00
exports.setting = async function (key) {
return await Settings.get(key);
2021-09-20 04:22:18 -04:00
};
2021-07-09 02:14:03 -04:00
/**
* 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<void>}
*/
2021-10-09 12:16:13 -04:00
exports.setSetting = async function (key, value, type = null) {
await Settings.set(key, value, type);
2021-09-20 04:22:18 -04:00
};
2021-07-21 14:02:35 -04:00
/**
* Get settings based on type
* @param {string} type The type of setting
* @returns {Promise<Bean>}
*/
2021-07-09 02:14:03 -04:00
exports.getSettings = async function (type) {
return await Settings.getSettings(type);
2021-09-20 04:22:18 -04:00
};
/**
* Set settings based on type
* @param {string} type Type of settings to set
* @param {Object} data Values of settings
* @returns {Promise<void>}
*/
2021-07-31 09:57:58 -04:00
exports.setSettings = async function (type, data) {
await Settings.setSettings(type, data);
2021-09-20 04:22:18 -04:00
};
2021-07-31 09:57:58 -04:00
// 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
*/
2021-10-01 06:44:32 -04:00
const parseCertificateInfo = function (info) {
let link = info;
let i = 0;
const existingList = {};
2021-10-01 06:44:32 -04:00
while (link) {
log.debug("cert", `[${i}] ${link.fingerprint}`);
2021-10-01 06:44:32 -04:00
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;
2021-10-01 06:44:32 -04:00
// Move up the chain until loop is encountered
if (link.issuerCertificate == null) {
link.certType = (i === 0) ? "self-signed" : "root CA";
2021-10-01 06:44:32 -04:00
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";
2021-10-01 06:44:32 -04:00
link.issuerCertificate = null;
break;
} else {
link.certType = (i === 0) ? "server" : "intermediate CA";
2021-10-01 06:44:32 -04:00
link = link.issuerCertificate;
}
// Should be no use, but just in case.
if (i > 500) {
throw new Error("Dead loop occurred in parseCertificateInfo");
}
i++;
}
2021-10-01 06:44:32 -04:00
return info;
};
/**
* Check if certificate is valid
* @param {Object} res Response object from axios
* @returns {Object} Object containing certificate information
*/
2021-10-01 06:44:32 -04:00
exports.checkCertificate = function (res) {
if (!res.request.res.socket) {
throw new Error("No socket found");
}
2021-10-01 06:44:32 -04:00
const info = res.request.res.socket.getPeerCertificate(true);
const valid = res.request.res.socket.authorized || false;
log.debug("cert", "Parsing Certificate Info");
2021-10-01 06:44:32 -04:00
const parsedInfo = parseCertificateInfo(info);
return {
2021-10-01 06:44:32 -04:00
valid: valid,
certInfo: parsedInfo
};
2021-09-20 04:22:18 -04:00
};
/**
* Check if the provided status code is within the accepted ranges
2022-07-18 10:06:25 -04:00
* @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;
2021-09-20 04:22:18 -04:00
};
/**
* 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;
2021-11-03 21:46:43 -04:00
if (!sockets) {
return 0;
}
const adapter = sockets.adapter;
2021-11-03 21:46:43 -04:00
if (!adapter) {
return 0;
}
const room = adapter.rooms.get(roomName);
if (room) {
return room.size;
} else {
return 0;
}
2021-09-20 04:22:18 -04:00
};
2021-09-11 07:40:03 -04:00
/**
* Allow CORS all origins if development
* @param {Object} res Response object from axios
*/
2021-09-11 07:40:03 -04:00
exports.allowDevAllOrigin = (res) => {
if (process.env.NODE_ENV === "development") {
exports.allowAllOrigin(res);
}
2021-09-20 04:22:18 -04:00
};
2021-09-11 07:40:03 -04:00
/**
* Allow CORS all origins
* @param {Object} res Response object from axios
*/
2021-09-11 07:40:03 -04:00
exports.allowAllOrigin = (res) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
2021-09-20 04:22:18 -04:00
};
2021-09-16 10:48:28 -04:00
/**
* Check if a user is logged in
* @param {Socket} socket Socket instance
*/
2021-09-16 10:48:28 -04:00
exports.checkLogin = (socket) => {
2021-11-03 21:46:43 -04:00
if (!socket.userID) {
2021-09-16 10:48:28 -04:00
throw new Error("You are not logged in.");
}
2021-09-20 04:22:18 -04:00
};
2022-03-29 05:38:48 -04:00
/**
* For logged-in users, double-check the password
* @param {Socket} socket Socket.io instance
* @param {string} currentPassword
2022-03-29 05:38:48 -04:00
* @returns {Promise<Bean>}
*/
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";
2022-10-05 02:26:30 -04:00
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);
2021-10-05 08:40:40 -04:00
process.exit(code);
});
};
/** Start end-to-end tests */
2022-06-16 05:28:17 -04:00
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();
};
2022-01-03 10:04:37 -05:00
/**
* 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
2022-01-03 10:04:37 -05:00
* @param {number} minHue, int
* @returns {string}, hex value
*/
2022-01-03 09:48:52 -05:00
exports.percentageToColor = (percentage, maxHue = 90, minHue = 10) => {
const hue = percentage * (maxHue - minHue) + minHue;
try {
return chroma(`hsl(${hue}, 90%, 40%)`).hex();
} catch (err) {
2022-01-04 06:21:53 -05:00
return badgeConstants.naColor;
2022-01-03 09:48:52 -05:00
}
};
/**
* 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);
};
2022-06-01 01:05:12 -04:00
/**
* Send an Error response
2022-06-01 01:05:12 -04:00
* @param {Object} res Express response object
* @param {string} [msg=""] Message to send
*/
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,
});
}
2022-06-01 01:05:12 -04:00
};
function timeObjectConvertTimezone(obj, timezone, timeObjectToUTC = true) {
2022-10-10 08:48:11 -04:00
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;
}
2022-10-10 08:48:11 -04:00
/**
*
* @param {object} obj
* @param {string} timezone
* @returns {object}
*/
module.exports.timeObjectToUTC = (obj, timezone = undefined) => {
return timeObjectConvertTimezone(obj, timezone, true);
};
2022-10-10 08:48:11 -04:00
/**
*
* @param {object} obj
* @param {string} timezone
* @returns {object}
*/
module.exports.timeObjectToLocal = (obj, timezone = undefined) => {
return timeObjectConvertTimezone(obj, timezone, false);
};
2022-10-26 09:41:21 -04:00
/**
* 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: ""
});
}
});
};
2023-08-03 13:10:15 -04:00
/**
* 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
* @param {number} len
* @return {string}
*/
module.exports.shake256 = (data, len) => {
if (!data) {
return "";
}
return crypto.createHash("shake256", { outputLength: len })
.update(data)
.digest("hex");
};
2023-08-03 13:10:15 -04:00
// 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;
}
}
};