mirror of
https://github.com/louislam/uptime-kuma.git
synced 2026-01-31 11:03:11 +08:00
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Frank Elsinga <frank@elsinga.de>
2173 lines
85 KiB
JavaScript
2173 lines
85 KiB
JavaScript
const dayjs = require("dayjs");
|
||
const axios = require("axios");
|
||
const { Prometheus } = require("../prometheus");
|
||
const {
|
||
log,
|
||
UP,
|
||
DOWN,
|
||
PENDING,
|
||
MAINTENANCE,
|
||
flipStatus,
|
||
MAX_INTERVAL_SECOND,
|
||
MIN_INTERVAL_SECOND,
|
||
SQL_DATETIME_FORMAT,
|
||
evaluateJsonQuery,
|
||
PING_PACKET_SIZE_MIN,
|
||
PING_PACKET_SIZE_MAX,
|
||
PING_PACKET_SIZE_DEFAULT,
|
||
PING_GLOBAL_TIMEOUT_MIN,
|
||
PING_GLOBAL_TIMEOUT_MAX,
|
||
PING_GLOBAL_TIMEOUT_DEFAULT,
|
||
PING_COUNT_MIN,
|
||
PING_COUNT_MAX,
|
||
PING_COUNT_DEFAULT,
|
||
PING_PER_REQUEST_TIMEOUT_MIN,
|
||
PING_PER_REQUEST_TIMEOUT_MAX,
|
||
PING_PER_REQUEST_TIMEOUT_DEFAULT,
|
||
RESPONSE_BODY_LENGTH_DEFAULT,
|
||
RESPONSE_BODY_LENGTH_MAX,
|
||
} = require("../../src/util");
|
||
const {
|
||
ping,
|
||
checkCertificate,
|
||
checkStatusCode,
|
||
getTotalClientInRoom,
|
||
setting,
|
||
setSetting,
|
||
httpNtlm,
|
||
radius,
|
||
kafkaProducerAsync,
|
||
getOidcTokenClientCredentials,
|
||
rootCertificatesFingerprints,
|
||
axiosAbortSignal,
|
||
checkCertificateHostname,
|
||
} = require("../util-server");
|
||
const { R } = require("redbean-node");
|
||
const { BeanModel } = require("redbean-node/dist/bean-model");
|
||
const { Notification } = require("../notification");
|
||
const { Proxy } = require("../proxy");
|
||
const { demoMode } = require("../config");
|
||
const version = require("../../package.json").version;
|
||
const apicache = require("../modules/apicache");
|
||
const { UptimeKumaServer } = require("../uptime-kuma-server");
|
||
const { DockerHost } = require("../docker");
|
||
const jwt = require("jsonwebtoken");
|
||
const crypto = require("crypto");
|
||
const { UptimeCalculator } = require("../uptime-calculator");
|
||
const { CookieJar } = require("tough-cookie");
|
||
const { HttpsCookieAgent } = require("http-cookie-agent/http");
|
||
const https = require("https");
|
||
const http = require("http");
|
||
const zlib = require("node:zlib");
|
||
const { promisify } = require("node:util");
|
||
const brotliCompress = promisify(zlib.brotliCompress);
|
||
const DomainExpiry = require("./domain_expiry");
|
||
|
||
const rootCertificates = rootCertificatesFingerprints();
|
||
|
||
/**
|
||
* status:
|
||
* 0 = DOWN
|
||
* 1 = UP
|
||
* 2 = PENDING
|
||
* 3 = MAINTENANCE
|
||
*/
|
||
class Monitor extends BeanModel {
|
||
/**
|
||
* Return an object that ready to parse to JSON for public Only show
|
||
* necessary data to public
|
||
* @param {boolean} showTags Include tags in JSON
|
||
* @param {boolean} certExpiry Include certificate expiry info in
|
||
* JSON
|
||
* @returns {Promise<object>} Object ready to parse
|
||
*/
|
||
async toPublicJSON(showTags = false, certExpiry = false) {
|
||
let obj = {
|
||
id: this.id,
|
||
name: this.name,
|
||
sendUrl: this.sendUrl,
|
||
type: this.type,
|
||
};
|
||
|
||
if (this.sendUrl) {
|
||
obj.url = this.customUrl ?? this.url;
|
||
}
|
||
|
||
if (showTags) {
|
||
obj.tags = await this.getTags();
|
||
}
|
||
|
||
if (
|
||
certExpiry &&
|
||
(this.type === "http" || this.type === "keyword" || this.type === "json-query") &&
|
||
this.getURLProtocol() === "https:"
|
||
) {
|
||
const { certExpiryDaysRemaining, validCert } = await this.getCertExpiry(this.id);
|
||
obj.certExpiryDaysRemaining = certExpiryDaysRemaining;
|
||
obj.validCert = validCert;
|
||
}
|
||
|
||
return obj;
|
||
}
|
||
|
||
/**
|
||
* Return an object that ready to parse to JSON
|
||
* @param {object} preloadData to prevent n+1 problems, we query the data in a batch outside of this function
|
||
* @param {boolean} includeSensitiveData Include sensitive data in
|
||
* JSON
|
||
* @returns {object} Object ready to parse
|
||
*/
|
||
toJSON(preloadData = {}, includeSensitiveData = true) {
|
||
let screenshot = null;
|
||
|
||
if (this.type === "real-browser") {
|
||
screenshot = "/screenshots/" + jwt.sign(this.id, UptimeKumaServer.getInstance().jwtSecret) + ".png";
|
||
}
|
||
|
||
const path = preloadData.paths.get(this.id) || [];
|
||
const pathName = path.join(" / ");
|
||
|
||
let data = {
|
||
id: this.id,
|
||
name: this.name,
|
||
description: this.description,
|
||
path,
|
||
pathName,
|
||
parent: this.parent,
|
||
childrenIDs: preloadData.childrenIDs.get(this.id) || [],
|
||
url: this.url,
|
||
wsIgnoreSecWebsocketAcceptHeader: this.getWsIgnoreSecWebsocketAcceptHeader(),
|
||
wsSubprotocol: this.wsSubprotocol,
|
||
method: this.method,
|
||
hostname: this.hostname,
|
||
port: this.port,
|
||
maxretries: this.maxretries,
|
||
weight: this.weight,
|
||
active: preloadData.activeStatus.get(this.id),
|
||
forceInactive: preloadData.forceInactive.get(this.id),
|
||
type: this.type,
|
||
timeout: this.timeout,
|
||
interval: this.interval,
|
||
retryInterval: this.retryInterval,
|
||
retryOnlyOnStatusCodeFailure: Boolean(this.retry_only_on_status_code_failure),
|
||
resendInterval: this.resendInterval,
|
||
keyword: this.keyword,
|
||
invertKeyword: this.isInvertKeyword(),
|
||
expiryNotification: this.isEnabledExpiryNotification(),
|
||
domainExpiryNotification: Boolean(this.domainExpiryNotification),
|
||
ignoreTls: this.getIgnoreTls(),
|
||
upsideDown: this.isUpsideDown(),
|
||
packetSize: this.packetSize,
|
||
maxredirects: this.maxredirects,
|
||
accepted_statuscodes: this.getAcceptedStatuscodes(),
|
||
dns_resolve_type: this.dns_resolve_type,
|
||
dns_resolve_server: this.dns_resolve_server,
|
||
dns_last_result: this.dns_last_result,
|
||
docker_container: this.docker_container,
|
||
docker_host: this.docker_host,
|
||
proxyId: this.proxy_id,
|
||
notificationIDList: preloadData.notifications.get(this.id) || {},
|
||
tags: preloadData.tags.get(this.id) || [],
|
||
maintenance: preloadData.maintenanceStatus.get(this.id),
|
||
mqttTopic: this.mqttTopic,
|
||
mqttSuccessMessage: this.mqttSuccessMessage,
|
||
mqttCheckType: this.mqttCheckType,
|
||
databaseQuery: this.databaseQuery,
|
||
authMethod: this.authMethod,
|
||
grpcUrl: this.grpcUrl,
|
||
grpcProtobuf: this.grpcProtobuf,
|
||
grpcMethod: this.grpcMethod,
|
||
grpcServiceName: this.grpcServiceName,
|
||
grpcEnableTls: this.getGrpcEnableTls(),
|
||
radiusCalledStationId: this.radiusCalledStationId,
|
||
radiusCallingStationId: this.radiusCallingStationId,
|
||
game: this.game,
|
||
gamedigGivenPortOnly: this.getGameDigGivenPortOnly(),
|
||
httpBodyEncoding: this.httpBodyEncoding,
|
||
jsonPath: this.jsonPath,
|
||
expectedValue: this.expectedValue,
|
||
system_service_name: this.system_service_name,
|
||
kafkaProducerTopic: this.kafkaProducerTopic,
|
||
kafkaProducerBrokers: JSON.parse(this.kafkaProducerBrokers),
|
||
kafkaProducerSsl: this.getKafkaProducerSsl(),
|
||
kafkaProducerAllowAutoTopicCreation: this.getKafkaProducerAllowAutoTopicCreation(),
|
||
kafkaProducerMessage: this.kafkaProducerMessage,
|
||
screenshot,
|
||
cacheBust: this.getCacheBust(),
|
||
remote_browser: this.remote_browser,
|
||
snmpOid: this.snmpOid,
|
||
jsonPathOperator: this.jsonPathOperator,
|
||
snmpVersion: this.snmpVersion,
|
||
smtpSecurity: this.smtpSecurity,
|
||
rabbitmqNodes: JSON.parse(this.rabbitmqNodes),
|
||
conditions: JSON.parse(this.conditions),
|
||
ipFamily: this.ipFamily,
|
||
expectedTlsAlert: this.expected_tls_alert,
|
||
|
||
// ping advanced options
|
||
ping_numeric: this.isPingNumeric(),
|
||
ping_count: this.ping_count,
|
||
ping_per_request_timeout: this.ping_per_request_timeout,
|
||
|
||
// response saving options
|
||
saveResponse: this.getSaveResponse(),
|
||
saveErrorResponse: this.getSaveErrorResponse(),
|
||
responseMaxLength: this.response_max_length ?? RESPONSE_BODY_LENGTH_DEFAULT,
|
||
};
|
||
|
||
if (includeSensitiveData) {
|
||
data = {
|
||
...data,
|
||
headers: this.headers,
|
||
body: this.body,
|
||
grpcBody: this.grpcBody,
|
||
grpcMetadata: this.grpcMetadata,
|
||
basic_auth_user: this.basic_auth_user,
|
||
basic_auth_pass: this.basic_auth_pass,
|
||
oauth_client_id: this.oauth_client_id,
|
||
oauth_client_secret: this.oauth_client_secret,
|
||
oauth_token_url: this.oauth_token_url,
|
||
oauth_scopes: this.oauth_scopes,
|
||
oauth_audience: this.oauth_audience,
|
||
oauth_auth_method: this.oauth_auth_method,
|
||
pushToken: this.pushToken,
|
||
databaseConnectionString: this.databaseConnectionString,
|
||
radiusUsername: this.radiusUsername,
|
||
radiusPassword: this.radiusPassword,
|
||
radiusSecret: this.radiusSecret,
|
||
mqttUsername: this.mqttUsername,
|
||
mqttPassword: this.mqttPassword,
|
||
mqttWebsocketPath: this.mqttWebsocketPath,
|
||
authWorkstation: this.authWorkstation,
|
||
authDomain: this.authDomain,
|
||
tlsCa: this.tlsCa,
|
||
tlsCert: this.tlsCert,
|
||
tlsKey: this.tlsKey,
|
||
kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions),
|
||
rabbitmqUsername: this.rabbitmqUsername,
|
||
rabbitmqPassword: this.rabbitmqPassword,
|
||
};
|
||
}
|
||
|
||
data.includeSensitiveData = includeSensitiveData;
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Get all tags applied to this monitor
|
||
* @returns {Promise<LooseObject<any>[]>} List of tags on the
|
||
* monitor
|
||
*/
|
||
async getTags() {
|
||
return await R.getAll(
|
||
"SELECT mt.*, tag.name, tag.color FROM monitor_tag mt JOIN tag ON mt.tag_id = tag.id WHERE mt.monitor_id = ? ORDER BY tag.name",
|
||
[this.id]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Gets certificate expiry for this monitor
|
||
* @param {number} monitorID ID of monitor to send
|
||
* @returns {Promise<LooseObject<any>>} Certificate expiry info for
|
||
* monitor
|
||
*/
|
||
async getCertExpiry(monitorID) {
|
||
let tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [monitorID]);
|
||
let tlsInfo;
|
||
if (tlsInfoBean) {
|
||
tlsInfo = JSON.parse(tlsInfoBean?.info_json);
|
||
if (tlsInfo?.valid && tlsInfo?.certInfo?.daysRemaining) {
|
||
return {
|
||
certExpiryDaysRemaining: tlsInfo.certInfo.daysRemaining,
|
||
validCert: true,
|
||
};
|
||
}
|
||
}
|
||
return {
|
||
certExpiryDaysRemaining: "",
|
||
validCert: false,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Encode user and password to Base64 encoding
|
||
* for HTTP "basic" auth, as per RFC-7617
|
||
* @param {string|null} user - The username (nullable if not changed by a user)
|
||
* @param {string|null} pass - The password (nullable if not changed by a user)
|
||
* @returns {string} Encoded Base64 string
|
||
*/
|
||
encodeBase64(user, pass) {
|
||
return Buffer.from(`${user || ""}:${pass || ""}`).toString("base64");
|
||
}
|
||
|
||
/**
|
||
* Is the TLS expiry notification enabled?
|
||
* @returns {boolean} Enabled?
|
||
*/
|
||
isEnabledExpiryNotification() {
|
||
return Boolean(this.expiryNotification);
|
||
}
|
||
|
||
/**
|
||
* Check if ping should use numeric output only
|
||
* @returns {boolean} True if IP addresses will be output instead of symbolic hostnames
|
||
*/
|
||
isPingNumeric() {
|
||
return Boolean(this.ping_numeric);
|
||
}
|
||
|
||
/**
|
||
* Parse to boolean
|
||
* @returns {boolean} Should TLS errors be ignored?
|
||
*/
|
||
getIgnoreTls() {
|
||
return Boolean(this.ignoreTls);
|
||
}
|
||
|
||
/**
|
||
* Parse to boolean
|
||
* @returns {boolean} Should WS headers be ignored?
|
||
*/
|
||
getWsIgnoreSecWebsocketAcceptHeader() {
|
||
return Boolean(this.wsIgnoreSecWebsocketAcceptHeader);
|
||
}
|
||
|
||
/**
|
||
* Parse to boolean
|
||
* @returns {boolean} Is the monitor in upside down mode?
|
||
*/
|
||
isUpsideDown() {
|
||
return Boolean(this.upsideDown);
|
||
}
|
||
|
||
/**
|
||
* Parse to boolean
|
||
* @returns {boolean} Invert keyword match?
|
||
*/
|
||
isInvertKeyword() {
|
||
return Boolean(this.invertKeyword);
|
||
}
|
||
|
||
/**
|
||
* Parse to boolean
|
||
* @returns {boolean} Enable TLS for gRPC?
|
||
*/
|
||
getGrpcEnableTls() {
|
||
return Boolean(this.grpcEnableTls);
|
||
}
|
||
|
||
/**
|
||
* Parse to boolean
|
||
* @returns {boolean} if cachebusting is enabled
|
||
*/
|
||
getCacheBust() {
|
||
return Boolean(this.cacheBust);
|
||
}
|
||
|
||
/**
|
||
* Get accepted status codes
|
||
* @returns {object} Accepted status codes
|
||
*/
|
||
getAcceptedStatuscodes() {
|
||
return JSON.parse(this.accepted_statuscodes_json);
|
||
}
|
||
|
||
/**
|
||
* Get if game dig should only use the port which was provided
|
||
* @returns {boolean} gamedig should only use the provided port
|
||
*/
|
||
getGameDigGivenPortOnly() {
|
||
return Boolean(this.gamedigGivenPortOnly);
|
||
}
|
||
|
||
/**
|
||
* Parse to boolean
|
||
* @returns {boolean} Kafka Producer Ssl enabled?
|
||
*/
|
||
getKafkaProducerSsl() {
|
||
return Boolean(this.kafkaProducerSsl);
|
||
}
|
||
|
||
/**
|
||
* Parse to boolean
|
||
* @returns {boolean} Kafka Producer Allow Auto Topic Creation Enabled?
|
||
*/
|
||
getKafkaProducerAllowAutoTopicCreation() {
|
||
return Boolean(this.kafkaProducerAllowAutoTopicCreation);
|
||
}
|
||
|
||
/**
|
||
* Parse to boolean
|
||
* @returns {boolean} Should save response data on success?
|
||
*/
|
||
getSaveResponse() {
|
||
return Boolean(this.save_response);
|
||
}
|
||
|
||
/**
|
||
* Parse to boolean
|
||
* @returns {boolean} Should save response data on error?
|
||
*/
|
||
getSaveErrorResponse() {
|
||
return Boolean(this.save_error_response);
|
||
}
|
||
|
||
/**
|
||
* Start monitor
|
||
* @param {Server} io Socket server instance
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async start(io) {
|
||
let previousBeat = null;
|
||
let retries = 0;
|
||
|
||
try {
|
||
this.prometheus = new Prometheus(this, await this.getTags());
|
||
} catch (e) {
|
||
log.error("prometheus", "Please submit an issue to our GitHub repo. Prometheus update error: ", e.message);
|
||
}
|
||
|
||
const beat = async () => {
|
||
let beatInterval = this.interval;
|
||
|
||
if (!beatInterval) {
|
||
beatInterval = 1;
|
||
}
|
||
|
||
if (demoMode) {
|
||
if (beatInterval < 20) {
|
||
console.log("beat interval too low, reset to 20s");
|
||
beatInterval = 20;
|
||
}
|
||
}
|
||
|
||
// Expose here for prometheus update
|
||
// undefined if not https
|
||
let tlsInfo = undefined;
|
||
|
||
if (!previousBeat || this.type === "push") {
|
||
previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [this.id]);
|
||
if (previousBeat) {
|
||
retries = previousBeat.retries;
|
||
}
|
||
}
|
||
|
||
const isFirstBeat = !previousBeat;
|
||
|
||
let bean = R.dispense("heartbeat");
|
||
bean.monitor_id = this.id;
|
||
bean.time = R.isoDateTimeMillis(dayjs.utc());
|
||
bean.status = DOWN;
|
||
bean.downCount = previousBeat?.downCount || 0;
|
||
|
||
if (this.isUpsideDown()) {
|
||
bean.status = flipStatus(bean.status);
|
||
}
|
||
|
||
// Runtime patch timeout if it is 0
|
||
// See https://github.com/louislam/uptime-kuma/pull/3961#issuecomment-1804149144
|
||
if (!this.timeout || this.timeout <= 0) {
|
||
this.timeout = this.interval * 1000 * 0.8;
|
||
}
|
||
|
||
try {
|
||
if (await Monitor.isUnderMaintenance(this.id)) {
|
||
bean.msg = "Monitor under maintenance";
|
||
bean.status = MAINTENANCE;
|
||
} else if (this.type === "http" || this.type === "keyword" || this.type === "json-query") {
|
||
// Do not do any queries/high loading things before the "bean.ping"
|
||
let startTime = dayjs().valueOf();
|
||
|
||
// HTTP basic auth
|
||
let basicAuthHeader = {};
|
||
if (this.auth_method === "basic") {
|
||
basicAuthHeader = {
|
||
Authorization: "Basic " + this.encodeBase64(this.basic_auth_user, this.basic_auth_pass),
|
||
};
|
||
}
|
||
|
||
// OIDC: Basic client credential flow.
|
||
// Additional grants might be implemented in the future
|
||
let oauth2AuthHeader = {};
|
||
if (this.auth_method === "oauth2-cc") {
|
||
try {
|
||
if (
|
||
this.oauthAccessToken === undefined ||
|
||
new Date(this.oauthAccessToken.expires_at * 1000) <= new Date()
|
||
) {
|
||
this.oauthAccessToken = await this.makeOidcTokenClientCredentialsRequest();
|
||
}
|
||
oauth2AuthHeader = {
|
||
Authorization:
|
||
this.oauthAccessToken.token_type + " " + this.oauthAccessToken.access_token,
|
||
};
|
||
} catch (e) {
|
||
throw new Error("The oauth config is invalid. " + e.message);
|
||
}
|
||
}
|
||
|
||
let agentFamily = undefined;
|
||
if (this.ipFamily === "ipv4") {
|
||
agentFamily = 4;
|
||
}
|
||
if (this.ipFamily === "ipv6") {
|
||
agentFamily = 6;
|
||
}
|
||
|
||
const httpsAgentOptions = {
|
||
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
|
||
rejectUnauthorized: !this.getIgnoreTls(),
|
||
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
|
||
autoSelectFamily: true,
|
||
...(agentFamily ? { family: agentFamily } : {}),
|
||
};
|
||
|
||
const httpAgentOptions = {
|
||
maxCachedSessions: 0,
|
||
autoSelectFamily: true,
|
||
...(agentFamily ? { family: agentFamily } : {}),
|
||
};
|
||
|
||
log.debug("monitor", `[${this.name}] Prepare Options for axios`);
|
||
|
||
let contentType = null;
|
||
let bodyValue = null;
|
||
|
||
if (this.body && typeof this.body === "string" && this.body.trim().length > 0) {
|
||
if (!this.httpBodyEncoding || this.httpBodyEncoding === "json") {
|
||
try {
|
||
bodyValue = JSON.parse(this.body);
|
||
contentType = "application/json";
|
||
} catch (e) {
|
||
throw new Error("Your JSON body is invalid. " + e.message);
|
||
}
|
||
} else if (this.httpBodyEncoding === "form") {
|
||
bodyValue = this.body;
|
||
contentType = "application/x-www-form-urlencoded";
|
||
} else if (this.httpBodyEncoding === "xml") {
|
||
bodyValue = this.body;
|
||
contentType = "text/xml; charset=utf-8";
|
||
}
|
||
}
|
||
|
||
// Axios Options
|
||
const options = {
|
||
url: this.url,
|
||
method: (this.method || "get").toLowerCase(),
|
||
timeout: this.timeout * 1000,
|
||
headers: {
|
||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
|
||
...(contentType ? { "Content-Type": contentType } : {}),
|
||
...basicAuthHeader,
|
||
...oauth2AuthHeader,
|
||
...(this.headers ? JSON.parse(this.headers) : {}),
|
||
},
|
||
maxRedirects: this.maxredirects,
|
||
validateStatus: (status) => {
|
||
return checkStatusCode(status, this.getAcceptedStatuscodes());
|
||
},
|
||
signal: axiosAbortSignal((this.timeout + 10) * 1000),
|
||
};
|
||
|
||
if (bodyValue) {
|
||
options.data = bodyValue;
|
||
}
|
||
|
||
if (this.cacheBust) {
|
||
const randomFloatString = Math.random().toString(36);
|
||
const cacheBust = randomFloatString.substring(2);
|
||
options.params = {
|
||
uptime_kuma_cachebuster: cacheBust,
|
||
};
|
||
}
|
||
|
||
if (this.proxy_id) {
|
||
const proxy = await R.load("proxy", this.proxy_id);
|
||
|
||
if (proxy && proxy.active) {
|
||
const { httpAgent, httpsAgent } = Proxy.createAgents(proxy, {
|
||
httpsAgentOptions: httpsAgentOptions,
|
||
httpAgentOptions: httpAgentOptions,
|
||
});
|
||
|
||
options.proxy = false;
|
||
options.httpAgent = httpAgent;
|
||
options.httpsAgent = httpsAgent;
|
||
}
|
||
}
|
||
|
||
if (!options.httpAgent) {
|
||
options.httpAgent = new http.Agent(httpAgentOptions);
|
||
}
|
||
|
||
if (!options.httpsAgent) {
|
||
let jar = new CookieJar();
|
||
let httpsCookieAgentOptions = {
|
||
...httpsAgentOptions,
|
||
cookies: { jar },
|
||
};
|
||
options.httpsAgent = new HttpsCookieAgent(httpsCookieAgentOptions);
|
||
}
|
||
|
||
if (this.auth_method === "mtls") {
|
||
if (this.tlsCert !== null && this.tlsCert !== "") {
|
||
options.httpsAgent.options.cert = Buffer.from(this.tlsCert);
|
||
}
|
||
if (this.tlsCa !== null && this.tlsCa !== "") {
|
||
options.httpsAgent.options.ca = Buffer.from(this.tlsCa);
|
||
}
|
||
if (this.tlsKey !== null && this.tlsKey !== "") {
|
||
options.httpsAgent.options.key = Buffer.from(this.tlsKey);
|
||
}
|
||
}
|
||
|
||
let tlsInfo = {};
|
||
// Store tlsInfo when secureConnect event is emitted
|
||
// The keylog event listener is a workaround to access the tlsSocket
|
||
options.httpsAgent.once("keylog", async (line, tlsSocket) => {
|
||
tlsSocket.once("secureConnect", async () => {
|
||
tlsInfo = checkCertificate(tlsSocket);
|
||
tlsInfo.valid = tlsSocket.authorized || false;
|
||
tlsInfo.hostnameMatchMonitorUrl = checkCertificateHostname(
|
||
tlsInfo.certInfo.raw,
|
||
this.getUrl()?.hostname
|
||
);
|
||
|
||
await this.handleTlsInfo(tlsInfo);
|
||
});
|
||
});
|
||
|
||
log.debug("monitor", `[${this.name}] Axios Options: ${JSON.stringify(options)}`);
|
||
log.debug("monitor", `[${this.name}] Axios Request`);
|
||
|
||
// Make Request
|
||
let res = await this.makeAxiosRequest(options);
|
||
|
||
bean.msg = `${res.status} - ${res.statusText}`;
|
||
bean.ping = dayjs().valueOf() - startTime;
|
||
|
||
// in the frontend, the save response is only shown if the saveErrorResponse is set
|
||
if (this.getSaveResponse() && this.getSaveErrorResponse()) {
|
||
await this.saveResponseData(bean, res.data);
|
||
}
|
||
|
||
// fallback for if kelog event is not emitted, but we may still have tlsInfo,
|
||
// e.g. if the connection is made through a proxy
|
||
if (this.getUrl()?.protocol === "https:" && tlsInfo.valid === undefined) {
|
||
const tlsSocket = res.request.res.socket;
|
||
|
||
if (tlsSocket) {
|
||
tlsInfo = checkCertificate(tlsSocket);
|
||
tlsInfo.valid = tlsSocket.authorized || false;
|
||
tlsInfo.hostnameMatchMonitorUrl = checkCertificateHostname(
|
||
tlsInfo.certInfo.raw,
|
||
this.getUrl()?.hostname
|
||
);
|
||
|
||
await this.handleTlsInfo(tlsInfo);
|
||
}
|
||
}
|
||
|
||
// eslint-disable-next-line eqeqeq
|
||
if (process.env.UPTIME_KUMA_LOG_RESPONSE_BODY_MONITOR_ID == this.id) {
|
||
log.info("monitor", res.data);
|
||
}
|
||
|
||
if (this.type === "http") {
|
||
bean.status = UP;
|
||
} else if (this.type === "keyword") {
|
||
let data = res.data;
|
||
|
||
// Convert to string for object/array
|
||
if (typeof data !== "string") {
|
||
data = JSON.stringify(data);
|
||
}
|
||
|
||
let keywordFound = data.includes(this.keyword);
|
||
if (keywordFound === !this.isInvertKeyword()) {
|
||
bean.msg += ", keyword " + (keywordFound ? "is" : "not") + " found";
|
||
bean.status = UP;
|
||
} else {
|
||
data = data.replace(/<[^>]*>?|[\n\r]|\s+/gm, " ").trim();
|
||
if (data.length > 50) {
|
||
data = data.substring(0, 47) + "...";
|
||
}
|
||
throw new Error(
|
||
bean.msg +
|
||
", but keyword is " +
|
||
(keywordFound ? "present" : "not") +
|
||
" in [" +
|
||
data +
|
||
"]"
|
||
);
|
||
}
|
||
} else if (this.type === "json-query") {
|
||
let data = res.data;
|
||
|
||
const { status, response } = await evaluateJsonQuery(
|
||
data,
|
||
this.jsonPath,
|
||
this.jsonPathOperator,
|
||
this.expectedValue
|
||
);
|
||
|
||
if (status) {
|
||
bean.status = UP;
|
||
bean.msg = `JSON query passes (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`;
|
||
} else {
|
||
throw new Error(
|
||
`JSON query does not pass (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`
|
||
);
|
||
}
|
||
}
|
||
} else if (this.type === "ping") {
|
||
bean.ping = await ping(
|
||
this.hostname,
|
||
this.ping_count,
|
||
"",
|
||
this.ping_numeric,
|
||
this.packetSize,
|
||
this.timeout,
|
||
this.ping_per_request_timeout
|
||
);
|
||
bean.msg = "";
|
||
bean.status = UP;
|
||
} else if (this.type === "push") {
|
||
// Type: Push
|
||
log.debug(
|
||
"monitor",
|
||
`[${this.name}] Checking monitor at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`
|
||
);
|
||
const bufferTime = 1000; // 1s buffer to accommodate clock differences
|
||
|
||
if (previousBeat) {
|
||
const msSinceLastBeat = dayjs.utc().valueOf() - dayjs.utc(previousBeat.time).valueOf();
|
||
|
||
log.debug("monitor", `[${this.name}] msSinceLastBeat = ${msSinceLastBeat}`);
|
||
|
||
// If the previous beat was down or pending we use the regular
|
||
// beatInterval/retryInterval in the setTimeout further below
|
||
if (
|
||
previousBeat.status !== (this.isUpsideDown() ? DOWN : UP) ||
|
||
msSinceLastBeat > beatInterval * 1000 + bufferTime
|
||
) {
|
||
bean.duration = Math.round(msSinceLastBeat / 1000);
|
||
throw new Error("No heartbeat in the time window");
|
||
} else {
|
||
let timeout = beatInterval * 1000 - msSinceLastBeat;
|
||
if (timeout < 0) {
|
||
timeout = bufferTime;
|
||
} else {
|
||
timeout += bufferTime;
|
||
}
|
||
// No need to insert successful heartbeat for push type, so end here
|
||
retries = 0;
|
||
log.debug("monitor", `[${this.name}] timeout = ${timeout}`);
|
||
this.heartbeatInterval = setTimeout(safeBeat, timeout);
|
||
return;
|
||
}
|
||
} else {
|
||
bean.duration = beatInterval;
|
||
throw new Error("No heartbeat in the time window");
|
||
}
|
||
} else if (this.type === "steam") {
|
||
const steamApiUrl = "https://api.steampowered.com/IGameServersService/GetServerList/v1/";
|
||
const steamAPIKey = await setting("steamAPIKey");
|
||
const filter = `addr\\${this.hostname}:${this.port}`;
|
||
|
||
if (!steamAPIKey) {
|
||
throw new Error("Steam API Key not found");
|
||
}
|
||
|
||
let res = await axios.get(steamApiUrl, {
|
||
timeout: this.timeout * 1000,
|
||
headers: {
|
||
Accept: "*/*",
|
||
},
|
||
httpsAgent: new https.Agent({
|
||
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
|
||
rejectUnauthorized: !this.getIgnoreTls(),
|
||
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
|
||
}),
|
||
httpAgent: new http.Agent({
|
||
maxCachedSessions: 0,
|
||
}),
|
||
maxRedirects: this.maxredirects,
|
||
validateStatus: (status) => {
|
||
return checkStatusCode(status, this.getAcceptedStatuscodes());
|
||
},
|
||
params: {
|
||
filter: filter,
|
||
key: steamAPIKey,
|
||
},
|
||
});
|
||
|
||
if (res.data.response && res.data.response.servers && res.data.response.servers.length > 0) {
|
||
bean.status = UP;
|
||
bean.msg = res.data.response.servers[0].name;
|
||
|
||
try {
|
||
bean.ping = await ping(
|
||
this.hostname,
|
||
PING_COUNT_DEFAULT,
|
||
"",
|
||
true,
|
||
this.packetSize,
|
||
PING_GLOBAL_TIMEOUT_DEFAULT,
|
||
PING_PER_REQUEST_TIMEOUT_DEFAULT
|
||
);
|
||
} catch (_) {}
|
||
} else {
|
||
throw new Error("Server not found on Steam");
|
||
}
|
||
} else if (this.type === "docker") {
|
||
log.debug("monitor", `[${this.name}] Prepare Options for Axios`);
|
||
|
||
const options = {
|
||
url: `/containers/${this.docker_container}/json`,
|
||
timeout: this.interval * 1000 * 0.8,
|
||
headers: {
|
||
Accept: "*/*",
|
||
},
|
||
httpsAgent: new https.Agent({
|
||
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
|
||
rejectUnauthorized: !this.getIgnoreTls(),
|
||
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
|
||
}),
|
||
httpAgent: new http.Agent({
|
||
maxCachedSessions: 0,
|
||
}),
|
||
};
|
||
|
||
const dockerHost = await R.load("docker_host", this.docker_host);
|
||
|
||
if (!dockerHost) {
|
||
throw new Error("Failed to load docker host config");
|
||
}
|
||
|
||
if (dockerHost._dockerType === "socket") {
|
||
options.socketPath = dockerHost._dockerDaemon;
|
||
} else if (dockerHost._dockerType === "tcp") {
|
||
options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon);
|
||
options.httpsAgent = new https.Agent(
|
||
await DockerHost.getHttpsAgentOptions(dockerHost._dockerType, options.baseURL)
|
||
);
|
||
}
|
||
|
||
log.debug("monitor", `[${this.name}] Axios Request`);
|
||
let res = await axios.request(options);
|
||
|
||
if (!res.data.State) {
|
||
throw Error("Container state is not available");
|
||
}
|
||
if (!res.data.State.Running) {
|
||
throw Error("Container State is " + res.data.State.Status);
|
||
}
|
||
if (res.data.State.Paused) {
|
||
throw Error("Container is in a paused state");
|
||
}
|
||
if (res.data.State.Restarting) {
|
||
bean.status = PENDING;
|
||
bean.msg = "Container is reporting it is currently restarting";
|
||
} else if (res.data.State.Health && res.data.State.Health.Status !== "none") {
|
||
// if healthchecks are disabled (?), Health MAY not be present
|
||
if (res.data.State.Health.Status === "healthy") {
|
||
bean.status = UP;
|
||
bean.msg = "healthy";
|
||
} else if (res.data.State.Health.Status === "unhealthy") {
|
||
throw Error("Container State is unhealthy according to its healthcheck");
|
||
} else {
|
||
bean.status = PENDING;
|
||
bean.msg = res.data.State.Health.Status;
|
||
}
|
||
} else {
|
||
bean.status = UP;
|
||
bean.msg = `Container has not reported health and is currently ${res.data.State.Status}. As it is running, it is considered UP. Consider adding a health check for better service visibility`;
|
||
}
|
||
} else if (this.type === "radius") {
|
||
let startTime = dayjs().valueOf();
|
||
|
||
// Handle monitors that were created before the
|
||
// update and as such don't have a value for
|
||
// this.port.
|
||
let port;
|
||
if (this.port == null) {
|
||
port = 1812;
|
||
} else {
|
||
port = this.port;
|
||
}
|
||
|
||
const resp = await radius(
|
||
this.hostname,
|
||
this.radiusUsername,
|
||
this.radiusPassword,
|
||
this.radiusCalledStationId,
|
||
this.radiusCallingStationId,
|
||
this.radiusSecret,
|
||
port,
|
||
this.interval * 1000 * 0.4
|
||
);
|
||
|
||
bean.msg = resp.code;
|
||
bean.status = UP;
|
||
bean.ping = dayjs().valueOf() - startTime;
|
||
} else if (this.type in UptimeKumaServer.monitorTypeList) {
|
||
let startTime = dayjs().valueOf();
|
||
const monitorType = UptimeKumaServer.monitorTypeList[this.type];
|
||
await monitorType.check(this, bean, UptimeKumaServer.getInstance());
|
||
|
||
if (!monitorType.allowCustomStatus && bean.status !== UP) {
|
||
throw new Error(
|
||
"The monitor implementation is incorrect, non-UP error must throw error inside check()"
|
||
);
|
||
}
|
||
|
||
if (!bean.ping) {
|
||
bean.ping = dayjs().valueOf() - startTime;
|
||
}
|
||
} else if (this.type === "kafka-producer") {
|
||
let startTime = dayjs().valueOf();
|
||
|
||
bean.msg = await kafkaProducerAsync(
|
||
JSON.parse(this.kafkaProducerBrokers),
|
||
this.kafkaProducerTopic,
|
||
this.kafkaProducerMessage,
|
||
{
|
||
allowAutoTopicCreation: this.kafkaProducerAllowAutoTopicCreation,
|
||
ssl: this.kafkaProducerSsl,
|
||
clientId: `Uptime-Kuma/${version}`,
|
||
interval: this.interval,
|
||
},
|
||
JSON.parse(this.kafkaProducerSaslOptions)
|
||
);
|
||
bean.status = UP;
|
||
bean.ping = dayjs().valueOf() - startTime;
|
||
} else {
|
||
throw new Error("Unknown Monitor Type");
|
||
}
|
||
|
||
if (this.isUpsideDown()) {
|
||
bean.status = flipStatus(bean.status);
|
||
|
||
if (bean.status === DOWN) {
|
||
throw new Error("Flip UP to DOWN");
|
||
}
|
||
}
|
||
|
||
retries = 0;
|
||
} catch (error) {
|
||
if (error?.name === "CanceledError") {
|
||
bean.msg = `timeout by AbortSignal (${this.timeout}s)`;
|
||
} else {
|
||
bean.msg = error.message;
|
||
}
|
||
|
||
if (this.getSaveErrorResponse() && error?.response?.data !== undefined) {
|
||
await this.saveResponseData(bean, error.response.data);
|
||
}
|
||
|
||
// If UP come in here, it must be upside down mode
|
||
// Just reset the retries
|
||
if (this.isUpsideDown() && bean.status === UP) {
|
||
retries = 0;
|
||
} else if (this.type === "json-query" && this.retry_only_on_status_code_failure) {
|
||
// For json-query monitors with retry_only_on_status_code_failure enabled,
|
||
// only retry if the error is NOT from JSON query evaluation
|
||
// JSON query errors have the message "JSON query does not pass..."
|
||
const isJsonQueryError =
|
||
typeof error.message === "string" && error.message.includes("JSON query does not pass");
|
||
|
||
if (isJsonQueryError) {
|
||
// Don't retry on JSON query failures, mark as DOWN immediately
|
||
retries = 0;
|
||
} else if (this.maxretries > 0 && retries < this.maxretries) {
|
||
retries++;
|
||
bean.status = PENDING;
|
||
} else {
|
||
// Continue counting retries during DOWN
|
||
retries++;
|
||
}
|
||
} else {
|
||
// General retry logic for all other monitor types
|
||
if (this.maxretries > 0 && retries < this.maxretries) {
|
||
retries++;
|
||
bean.status = PENDING;
|
||
} else {
|
||
// Continue counting retries during DOWN
|
||
retries++;
|
||
}
|
||
}
|
||
}
|
||
|
||
bean.retries = retries;
|
||
|
||
log.debug("monitor", `[${this.name}] Check isImportant`);
|
||
let isImportant = Monitor.isImportantBeat(isFirstBeat, previousBeat?.status, bean.status);
|
||
|
||
// Mark as important if status changed, ignore pending pings,
|
||
// Don't notify if disrupted changes to up
|
||
if (isImportant) {
|
||
bean.important = true;
|
||
|
||
if (Monitor.isImportantForNotification(isFirstBeat, previousBeat?.status, bean.status)) {
|
||
log.debug("monitor", `[${this.name}] sendNotification`);
|
||
await Monitor.sendNotification(isFirstBeat, this, bean);
|
||
} else {
|
||
log.debug(
|
||
"monitor",
|
||
`[${this.name}] will not sendNotification because it is (or was) under maintenance`
|
||
);
|
||
}
|
||
|
||
// Reset down count
|
||
bean.downCount = 0;
|
||
|
||
// Clear Status Page Cache
|
||
log.debug("monitor", `[${this.name}] apicache clear`);
|
||
apicache.clear();
|
||
|
||
await UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);
|
||
} else {
|
||
bean.important = false;
|
||
|
||
if (bean.status === DOWN && this.resendInterval > 0) {
|
||
++bean.downCount;
|
||
if (bean.downCount >= this.resendInterval) {
|
||
// Send notification again, because we are still DOWN
|
||
log.debug(
|
||
"monitor",
|
||
`[${this.name}] sendNotification again: Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`
|
||
);
|
||
await Monitor.sendNotification(isFirstBeat, this, bean);
|
||
|
||
// Reset down count
|
||
bean.downCount = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (bean.status !== MAINTENANCE && Boolean(this.domainExpiryNotification)) {
|
||
try {
|
||
const supportInfo = await DomainExpiry.checkSupport(this);
|
||
const domainExpiryDate = await DomainExpiry.checkExpiry(supportInfo.domain);
|
||
if (domainExpiryDate) {
|
||
DomainExpiry.sendNotifications(
|
||
supportInfo.domain,
|
||
(await Monitor.getNotificationList(this)) || []
|
||
);
|
||
} else {
|
||
log.debug("monitor", `Failed getting expiration date for domain ${supportInfo.domain}`);
|
||
}
|
||
} catch (error) {
|
||
if (
|
||
error.message === "domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint" &&
|
||
Boolean(this.domainExpiryNotification)
|
||
) {
|
||
log.warn(
|
||
"domain_expiry",
|
||
`Domain expiry unsupported for '.${error.meta.publicSuffix}' because its RDAP endpoint is not listed in the IANA database.`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (bean.status === UP) {
|
||
log.debug(
|
||
"monitor",
|
||
`Monitor #${this.id} '${this.name}': Successful Response: ${bean.ping} ms | Interval: ${beatInterval} seconds | Type: ${this.type}`
|
||
);
|
||
} else if (bean.status === PENDING) {
|
||
if (this.retryInterval > 0) {
|
||
beatInterval = this.retryInterval;
|
||
}
|
||
log.warn(
|
||
"monitor",
|
||
`Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Max retries: ${this.maxretries} | Retry: ${retries} | Retry Interval: ${beatInterval} seconds | Type: ${this.type}`
|
||
);
|
||
} else if (bean.status === MAINTENANCE) {
|
||
log.warn("monitor", `Monitor #${this.id} '${this.name}': Under Maintenance | Type: ${this.type}`);
|
||
} else {
|
||
log.warn(
|
||
"monitor",
|
||
`Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type} | Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`
|
||
);
|
||
}
|
||
|
||
// Calculate uptime
|
||
let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(this.id);
|
||
let endTimeDayjs = await uptimeCalculator.update(bean.status, parseFloat(bean.ping));
|
||
bean.end_time = R.isoDateTimeMillis(endTimeDayjs);
|
||
|
||
// Send to frontend
|
||
log.debug("monitor", `[${this.name}] Send to socket`);
|
||
io.to(this.user_id).emit("heartbeat", bean.toJSON());
|
||
Monitor.sendStats(io, this.id, this.user_id);
|
||
|
||
// Store to database
|
||
log.debug("monitor", `[${this.name}] Store`);
|
||
await R.store(bean);
|
||
|
||
log.debug("monitor", `[${this.name}] prometheus.update`);
|
||
const data24h = uptimeCalculator.get24Hour();
|
||
const data30d = uptimeCalculator.get30Day();
|
||
const data1y = uptimeCalculator.get1Year();
|
||
this.prometheus?.update(bean, tlsInfo, { data24h, data30d, data1y });
|
||
|
||
previousBeat = bean;
|
||
|
||
if (!this.isStop) {
|
||
log.debug("monitor", `[${this.name}] SetTimeout for next check.`);
|
||
|
||
let intervalRemainingMs = Math.max(1, beatInterval * 1000 - dayjs().diff(dayjs.utc(bean.time)));
|
||
|
||
log.debug("monitor", `[${this.name}] Next heartbeat in: ${intervalRemainingMs}ms`);
|
||
|
||
this.heartbeatInterval = setTimeout(safeBeat, intervalRemainingMs);
|
||
} else {
|
||
log.info("monitor", `[${this.name}] isStop = true, no next check.`);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Get a heartbeat and handle errors7
|
||
* @returns {void}
|
||
*/
|
||
const safeBeat = async () => {
|
||
try {
|
||
await beat();
|
||
} catch (e) {
|
||
console.trace(e);
|
||
UptimeKumaServer.errorLog(e, false);
|
||
log.error("monitor", "Please report to https://github.com/louislam/uptime-kuma/issues");
|
||
|
||
if (!this.isStop) {
|
||
log.info("monitor", "Try to restart the monitor");
|
||
this.heartbeatInterval = setTimeout(safeBeat, this.interval * 1000);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Delay Push Type
|
||
if (this.type === "push") {
|
||
setTimeout(() => {
|
||
safeBeat();
|
||
}, this.interval * 1000);
|
||
} else {
|
||
safeBeat();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save response body to a heartbeat if response saving is enabled.
|
||
* @param {import("redbean-node").Bean} bean Heartbeat bean to populate.
|
||
* @param {unknown} data Response payload.
|
||
* @returns {void}
|
||
*/
|
||
async saveResponseData(bean, data) {
|
||
if (data === undefined) {
|
||
return;
|
||
}
|
||
|
||
let responseData = data;
|
||
if (typeof responseData !== "string") {
|
||
try {
|
||
responseData = JSON.stringify(responseData);
|
||
} catch (error) {
|
||
responseData = String(responseData);
|
||
}
|
||
}
|
||
|
||
const maxSize = this.response_max_length ?? RESPONSE_BODY_LENGTH_DEFAULT;
|
||
if (responseData.length > maxSize) {
|
||
responseData = responseData.substring(0, maxSize) + "... (truncated)";
|
||
}
|
||
|
||
// Offload brotli compression from main event loop to libuv thread pool
|
||
bean.response = (await brotliCompress(Buffer.from(responseData, "utf8"))).toString("base64");
|
||
}
|
||
|
||
/**
|
||
* Make a request using axios
|
||
* @param {object} options Options for Axios
|
||
* @param {boolean} finalCall Should this be the final call i.e
|
||
* don't retry on failure
|
||
* @returns {object} Axios response
|
||
*/
|
||
async makeAxiosRequest(options, finalCall = false) {
|
||
try {
|
||
let res;
|
||
if (this.auth_method === "ntlm") {
|
||
options.httpsAgent.keepAlive = true;
|
||
|
||
res = await httpNtlm(options, {
|
||
username: this.basic_auth_user,
|
||
password: this.basic_auth_pass,
|
||
domain: this.authDomain,
|
||
workstation: this.authWorkstation ? this.authWorkstation : undefined,
|
||
});
|
||
} else {
|
||
res = await axios.request(options);
|
||
}
|
||
|
||
return res;
|
||
} catch (error) {
|
||
/**
|
||
* Make a single attempt to obtain an new access token in the event that
|
||
* the recent api request failed for authentication purposes
|
||
*/
|
||
if (this.auth_method === "oauth2-cc" && error.response.status === 401 && !finalCall) {
|
||
this.oauthAccessToken = await this.makeOidcTokenClientCredentialsRequest();
|
||
let oauth2AuthHeader = {
|
||
Authorization: this.oauthAccessToken.token_type + " " + this.oauthAccessToken.access_token,
|
||
};
|
||
options.headers = { ...options.headers, ...oauth2AuthHeader };
|
||
|
||
return this.makeAxiosRequest(options, true);
|
||
}
|
||
|
||
// Fix #2253
|
||
// Read more: https://stackoverflow.com/questions/1759956/curl-error-18-transfer-closed-with-outstanding-read-data-remaining
|
||
if (
|
||
!finalCall &&
|
||
typeof error.message === "string" &&
|
||
error.message.includes("maxContentLength size of -1 exceeded")
|
||
) {
|
||
log.debug("monitor", "makeAxiosRequest with gzip");
|
||
options.headers["Accept-Encoding"] = "gzip, deflate";
|
||
return this.makeAxiosRequest(options, true);
|
||
} else {
|
||
if (
|
||
typeof error.message === "string" &&
|
||
error.message.includes("maxContentLength size of -1 exceeded")
|
||
) {
|
||
error.message = "response timeout: incomplete response within a interval";
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stop monitor
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async stop() {
|
||
clearTimeout(this.heartbeatInterval);
|
||
this.isStop = true;
|
||
|
||
this.prometheus?.remove();
|
||
}
|
||
|
||
/**
|
||
* Get prometheus instance
|
||
* @returns {Prometheus|undefined} Current prometheus instance
|
||
*/
|
||
getPrometheus() {
|
||
return this.prometheus;
|
||
}
|
||
|
||
/**
|
||
* Helper Method:
|
||
* returns URL object for further usage
|
||
* returns null if url is invalid
|
||
* @returns {(null|URL)} Monitor URL
|
||
*/
|
||
getUrl() {
|
||
try {
|
||
return new URL(this.url);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Example: http: or https:
|
||
* @returns {(null|string)} URL's protocol
|
||
*/
|
||
getURLProtocol() {
|
||
const url = this.getUrl();
|
||
if (url) {
|
||
return this.getUrl().protocol;
|
||
} else {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Store TLS info to database
|
||
* @param {object} checkCertificateResult Certificate to update
|
||
* @returns {Promise<object>} Updated certificate
|
||
*/
|
||
async updateTlsInfo(checkCertificateResult) {
|
||
let tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [this.id]);
|
||
|
||
if (tlsInfoBean == null) {
|
||
tlsInfoBean = R.dispense("monitor_tls_info");
|
||
tlsInfoBean.monitor_id = this.id;
|
||
} else {
|
||
// Clear sent history if the cert changed.
|
||
try {
|
||
let oldCertInfo = JSON.parse(tlsInfoBean.info_json);
|
||
|
||
let isValidObjects =
|
||
oldCertInfo && oldCertInfo.certInfo && checkCertificateResult && checkCertificateResult.certInfo;
|
||
|
||
if (isValidObjects) {
|
||
if (oldCertInfo.certInfo.fingerprint256 !== checkCertificateResult.certInfo.fingerprint256) {
|
||
log.debug("monitor", "Resetting sent_history");
|
||
await R.exec(
|
||
"DELETE FROM notification_sent_history WHERE type = 'certificate' AND monitor_id = ?",
|
||
[this.id]
|
||
);
|
||
} else {
|
||
log.debug("monitor", "No need to reset sent_history");
|
||
log.debug("monitor", oldCertInfo.certInfo.fingerprint256);
|
||
log.debug("monitor", checkCertificateResult.certInfo.fingerprint256);
|
||
}
|
||
} else {
|
||
log.debug("monitor", "Not valid object");
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
tlsInfoBean.info_json = JSON.stringify(checkCertificateResult);
|
||
await R.store(tlsInfoBean);
|
||
|
||
return checkCertificateResult;
|
||
}
|
||
|
||
/**
|
||
* Checks if the monitor is active based on itself and its parents
|
||
* @param {number} monitorID ID of monitor to send
|
||
* @param {boolean} active is active
|
||
* @returns {Promise<boolean>} Is the monitor active?
|
||
*/
|
||
static async isActive(monitorID, active) {
|
||
const parentActive = await Monitor.isParentActive(monitorID);
|
||
|
||
return active === 1 && parentActive;
|
||
}
|
||
|
||
/**
|
||
* Send statistics to clients
|
||
* @param {Server} io Socket server instance
|
||
* @param {number} monitorID ID of monitor to send
|
||
* @param {number} userID ID of user to send to
|
||
* @returns {void}
|
||
*/
|
||
static async sendStats(io, monitorID, userID) {
|
||
const hasClients = getTotalClientInRoom(io, userID) > 0;
|
||
let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
|
||
|
||
if (hasClients) {
|
||
// Send 24 hour average ping
|
||
let data24h = await uptimeCalculator.get24Hour();
|
||
io.to(userID).emit("avgPing", monitorID, data24h.avgPing ? Number(data24h.avgPing.toFixed(2)) : null);
|
||
|
||
// Send 24 hour uptime
|
||
io.to(userID).emit("uptime", monitorID, 24, data24h.uptime);
|
||
|
||
// Send 30 day uptime
|
||
let data30d = await uptimeCalculator.get30Day();
|
||
io.to(userID).emit("uptime", monitorID, 720, data30d.uptime);
|
||
|
||
// Send 1-year uptime
|
||
let data1y = await uptimeCalculator.get1Year();
|
||
io.to(userID).emit("uptime", monitorID, "1y", data1y.uptime);
|
||
|
||
// Send Cert Info
|
||
await Monitor.sendCertInfo(io, monitorID, userID);
|
||
|
||
// Send domain info
|
||
await Monitor.sendDomainInfo(io, monitorID, userID);
|
||
} else {
|
||
log.debug("monitor", "No clients in the room, no need to send stats");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send certificate information to client
|
||
* @param {Server} io Socket server instance
|
||
* @param {number} monitorID ID of monitor to send
|
||
* @param {number} userID ID of user to send to
|
||
* @returns {void}
|
||
*/
|
||
static async sendCertInfo(io, monitorID, userID) {
|
||
let tlsInfo = await R.findOne("monitor_tls_info", "monitor_id = ?", [monitorID]);
|
||
if (tlsInfo != null) {
|
||
io.to(userID).emit("certInfo", monitorID, tlsInfo.info_json);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send domain name information to client
|
||
* @param {Server} io Socket server instance
|
||
* @param {number} monitorID ID of monitor to send
|
||
* @param {number} userID ID of user to send to
|
||
* @returns {void}
|
||
*/
|
||
static async sendDomainInfo(io, monitorID, userID) {
|
||
const monitor = await R.findOne("monitor", "id = ?", [monitorID]);
|
||
|
||
try {
|
||
const supportInfo = await DomainExpiry.checkSupport(monitor);
|
||
const domain = await DomainExpiry.findByDomainNameOrCreate(supportInfo.domain);
|
||
if (domain?.expiry) {
|
||
io.to(userID).emit("domainInfo", monitorID, domain.daysRemaining, new Date(domain.expiry));
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
/**
|
||
* Has status of monitor changed since last beat?
|
||
* @param {boolean} isFirstBeat Is this the first beat of this monitor?
|
||
* @param {const} previousBeatStatus Status of the previous beat
|
||
* @param {const} currentBeatStatus Status of the current beat
|
||
* @returns {boolean} True if is an important beat else false
|
||
*/
|
||
static isImportantBeat(isFirstBeat, previousBeatStatus, currentBeatStatus) {
|
||
// * ? -> ANY STATUS = important [isFirstBeat]
|
||
// UP -> PENDING = not important
|
||
// * UP -> DOWN = important
|
||
// UP -> UP = not important
|
||
// PENDING -> PENDING = not important
|
||
// * PENDING -> DOWN = important
|
||
// PENDING -> UP = not important
|
||
// DOWN -> PENDING = this case not exists
|
||
// DOWN -> DOWN = not important
|
||
// * DOWN -> UP = important
|
||
// MAINTENANCE -> MAINTENANCE = not important
|
||
// * MAINTENANCE -> UP = important
|
||
// * MAINTENANCE -> DOWN = important
|
||
// * DOWN -> MAINTENANCE = important
|
||
// * UP -> MAINTENANCE = important
|
||
return (
|
||
isFirstBeat ||
|
||
(previousBeatStatus === DOWN && currentBeatStatus === MAINTENANCE) ||
|
||
(previousBeatStatus === UP && currentBeatStatus === MAINTENANCE) ||
|
||
(previousBeatStatus === MAINTENANCE && currentBeatStatus === DOWN) ||
|
||
(previousBeatStatus === MAINTENANCE && currentBeatStatus === UP) ||
|
||
(previousBeatStatus === UP && currentBeatStatus === DOWN) ||
|
||
(previousBeatStatus === DOWN && currentBeatStatus === UP) ||
|
||
(previousBeatStatus === PENDING && currentBeatStatus === DOWN)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Is this beat important for notifications?
|
||
* @param {boolean} isFirstBeat Is this the first beat of this monitor?
|
||
* @param {const} previousBeatStatus Status of the previous beat
|
||
* @param {const} currentBeatStatus Status of the current beat
|
||
* @returns {boolean} True if is an important beat else false
|
||
*/
|
||
static isImportantForNotification(isFirstBeat, previousBeatStatus, currentBeatStatus) {
|
||
// * ? -> ANY STATUS = important [isFirstBeat]
|
||
// UP -> PENDING = not important
|
||
// * UP -> DOWN = important
|
||
// UP -> UP = not important
|
||
// PENDING -> PENDING = not important
|
||
// * PENDING -> DOWN = important
|
||
// PENDING -> UP = not important
|
||
// DOWN -> PENDING = this case not exists
|
||
// DOWN -> DOWN = not important
|
||
// * DOWN -> UP = important
|
||
// MAINTENANCE -> MAINTENANCE = not important
|
||
// MAINTENANCE -> UP = not important
|
||
// * MAINTENANCE -> DOWN = important
|
||
// DOWN -> MAINTENANCE = not important
|
||
// UP -> MAINTENANCE = not important
|
||
return (
|
||
isFirstBeat ||
|
||
(previousBeatStatus === MAINTENANCE && currentBeatStatus === DOWN) ||
|
||
(previousBeatStatus === UP && currentBeatStatus === DOWN) ||
|
||
(previousBeatStatus === DOWN && currentBeatStatus === UP) ||
|
||
(previousBeatStatus === PENDING && currentBeatStatus === DOWN)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Send a notification about a monitor
|
||
* @param {boolean} isFirstBeat Is this beat the first of this monitor?
|
||
* @param {Monitor} monitor The monitor to send a notification about
|
||
* @param {import("./heartbeat")} bean Status information about monitor
|
||
* @returns {Promise<void>}
|
||
*/
|
||
static async sendNotification(isFirstBeat, monitor, bean) {
|
||
if (!isFirstBeat || bean.status === DOWN) {
|
||
const notificationList = await Monitor.getNotificationList(monitor);
|
||
|
||
let text;
|
||
if (bean.status === UP) {
|
||
text = "✅ Up";
|
||
} else {
|
||
text = "🔴 Down";
|
||
}
|
||
|
||
let msg = `[${monitor.name}] [${text}] ${bean.msg}`;
|
||
|
||
const heartbeatJSON = await bean.toJSONAsync({ decodeResponse: true });
|
||
const monitorData = [{ id: monitor.id, active: monitor.active, name: monitor.name }];
|
||
const preloadData = await Monitor.preparePreloadData(monitorData);
|
||
// Prevent if the msg is undefined, notifications such as Discord cannot send out.
|
||
if (!heartbeatJSON["msg"]) {
|
||
heartbeatJSON["msg"] = "N/A";
|
||
}
|
||
|
||
// Also provide the time in server timezone
|
||
heartbeatJSON["timezone"] = await UptimeKumaServer.getInstance().getTimezone();
|
||
heartbeatJSON["timezoneOffset"] = UptimeKumaServer.getInstance().getTimezoneOffset();
|
||
heartbeatJSON["localDateTime"] = dayjs
|
||
.utc(heartbeatJSON["time"])
|
||
.tz(heartbeatJSON["timezone"])
|
||
.format(SQL_DATETIME_FORMAT);
|
||
|
||
// Calculate downtime tracking information when service comes back up
|
||
// This makes downtime information available to all notification providers
|
||
if (bean.status === UP && monitor.id) {
|
||
try {
|
||
const lastDownHeartbeat = await R.getRow(
|
||
"SELECT time FROM heartbeat WHERE monitor_id = ? AND status = ? ORDER BY time DESC LIMIT 1",
|
||
[monitor.id, DOWN]
|
||
);
|
||
|
||
if (lastDownHeartbeat && lastDownHeartbeat.time) {
|
||
heartbeatJSON["lastDownTime"] = lastDownHeartbeat.time;
|
||
}
|
||
} catch (error) {
|
||
// If we can't calculate downtime, just continue without it
|
||
// Silently fail to avoid disrupting notification sending
|
||
log.debug(
|
||
"monitor",
|
||
`[${monitor.name}] Could not calculate downtime information: ${error.message}`
|
||
);
|
||
}
|
||
}
|
||
|
||
for (let notification of notificationList) {
|
||
try {
|
||
await Notification.send(
|
||
JSON.parse(notification.config),
|
||
msg,
|
||
monitor.toJSON(preloadData, false),
|
||
heartbeatJSON
|
||
);
|
||
} catch (e) {
|
||
log.error("monitor", "Cannot send notification to " + notification.name);
|
||
log.error("monitor", e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get list of notification providers for a given monitor
|
||
* @param {Monitor} monitor Monitor to get notification providers for
|
||
* @returns {Promise<LooseObject<any>[]>} List of notifications
|
||
*/
|
||
static async getNotificationList(monitor) {
|
||
let notificationList = await R.getAll(
|
||
"SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id ",
|
||
[monitor.id]
|
||
);
|
||
return notificationList;
|
||
}
|
||
|
||
/**
|
||
* checks certificate chain for expiring certificates
|
||
* @param {object} tlsInfoObject Information about certificate
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async checkCertExpiryNotifications(tlsInfoObject) {
|
||
if (tlsInfoObject && tlsInfoObject.certInfo && tlsInfoObject.certInfo.daysRemaining) {
|
||
const notificationList = await Monitor.getNotificationList(this);
|
||
|
||
if (!notificationList.length > 0) {
|
||
// fail fast. If no notification is set, all the following checks can be skipped.
|
||
log.debug("monitor", "No notification, no need to send cert notification");
|
||
return;
|
||
}
|
||
|
||
let notifyDays = await setting("tlsExpiryNotifyDays");
|
||
if (notifyDays == null || !Array.isArray(notifyDays)) {
|
||
// Reset Default
|
||
await setSetting("tlsExpiryNotifyDays", [7, 14, 21], "general");
|
||
notifyDays = [7, 14, 21];
|
||
}
|
||
|
||
if (Array.isArray(notifyDays)) {
|
||
for (const targetDays of notifyDays) {
|
||
let certInfo = tlsInfoObject.certInfo;
|
||
while (certInfo) {
|
||
let subjectCN = certInfo.subject["CN"];
|
||
if (rootCertificates.has(certInfo.fingerprint256)) {
|
||
log.debug(
|
||
"monitor",
|
||
`Known root cert: ${certInfo.certType} certificate "${subjectCN}" (${certInfo.daysRemaining} days valid) on ${targetDays} deadline.`
|
||
);
|
||
break;
|
||
} else if (certInfo.daysRemaining > targetDays) {
|
||
log.debug(
|
||
"monitor",
|
||
`No need to send cert notification for ${certInfo.certType} certificate "${subjectCN}" (${certInfo.daysRemaining} days valid) on ${targetDays} deadline.`
|
||
);
|
||
} else {
|
||
log.debug(
|
||
"monitor",
|
||
`call sendCertNotificationByTargetDays for ${targetDays} deadline on certificate ${subjectCN}.`
|
||
);
|
||
await this.sendCertNotificationByTargetDays(
|
||
subjectCN,
|
||
certInfo.certType,
|
||
certInfo.daysRemaining,
|
||
targetDays,
|
||
notificationList
|
||
);
|
||
}
|
||
certInfo = certInfo.issuerCertificate;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send a certificate notification when certificate expires in less
|
||
* than target days
|
||
* @param {string} certCN Common Name attribute from the certificate subject
|
||
* @param {string} certType certificate type
|
||
* @param {number} daysRemaining Number of days remaining on certificate
|
||
* @param {number} targetDays Number of days to alert after
|
||
* @param {LooseObject<any>[]} notificationList List of notification providers
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async sendCertNotificationByTargetDays(certCN, certType, daysRemaining, targetDays, notificationList) {
|
||
let row = await R.getRow(
|
||
"SELECT * FROM notification_sent_history WHERE type = ? AND monitor_id = ? AND days <= ?",
|
||
["certificate", this.id, targetDays]
|
||
);
|
||
|
||
// Sent already, no need to send again
|
||
if (row) {
|
||
log.debug("monitor", "Sent already, no need to send again");
|
||
return;
|
||
}
|
||
|
||
let sent = false;
|
||
log.debug("monitor", "Send certificate notification");
|
||
|
||
for (let notification of notificationList) {
|
||
try {
|
||
log.debug("monitor", "Sending to " + notification.name);
|
||
await Notification.send(
|
||
JSON.parse(notification.config),
|
||
`[${this.name}][${this.url}] ${certType} certificate ${certCN} will expire in ${daysRemaining} days`
|
||
);
|
||
sent = true;
|
||
} catch (e) {
|
||
log.error("monitor", "Cannot send cert notification to " + notification.name);
|
||
log.error("monitor", e);
|
||
}
|
||
}
|
||
|
||
if (sent) {
|
||
await R.exec("INSERT INTO notification_sent_history (type, monitor_id, days) VALUES(?, ?, ?)", [
|
||
"certificate",
|
||
this.id,
|
||
targetDays,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get the status of the previous heartbeat
|
||
* @param {number} monitorID ID of monitor to check
|
||
* @returns {Promise<LooseObject<any>>} Previous heartbeat
|
||
*/
|
||
static async getPreviousHeartbeat(monitorID) {
|
||
return await R.findOne("heartbeat", " id = (select MAX(id) from heartbeat where monitor_id = ?)", [monitorID]);
|
||
}
|
||
|
||
/**
|
||
* Check if monitor is under maintenance
|
||
* @param {number} monitorID ID of monitor to check
|
||
* @returns {Promise<boolean>} Is the monitor under maintenance
|
||
*/
|
||
static async isUnderMaintenance(monitorID) {
|
||
const maintenanceIDList = await R.getCol(
|
||
`
|
||
SELECT maintenance_id FROM monitor_maintenance
|
||
WHERE monitor_id = ?
|
||
`,
|
||
[monitorID]
|
||
);
|
||
|
||
for (const maintenanceID of maintenanceIDList) {
|
||
const maintenance = await UptimeKumaServer.getInstance().getMaintenance(maintenanceID);
|
||
if (maintenance && (await maintenance.isUnderMaintenance())) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
const parent = await Monitor.getParent(monitorID);
|
||
if (parent != null) {
|
||
return await Monitor.isUnderMaintenance(parent.id);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Make sure monitor interval is between bounds
|
||
* @returns {void}
|
||
* @throws Interval is outside of range
|
||
*/
|
||
validate() {
|
||
if (this.interval > MAX_INTERVAL_SECOND) {
|
||
throw new Error(`Interval cannot be more than ${MAX_INTERVAL_SECOND} seconds`);
|
||
}
|
||
if (this.interval < MIN_INTERVAL_SECOND) {
|
||
throw new Error(`Interval cannot be less than ${MIN_INTERVAL_SECOND} seconds`);
|
||
}
|
||
|
||
if (this.retryInterval > MAX_INTERVAL_SECOND) {
|
||
throw new Error(`Retry interval cannot be more than ${MAX_INTERVAL_SECOND} seconds`);
|
||
}
|
||
if (this.retryInterval < MIN_INTERVAL_SECOND) {
|
||
throw new Error(`Retry interval cannot be less than ${MIN_INTERVAL_SECOND} seconds`);
|
||
}
|
||
|
||
if (this.response_max_length !== undefined) {
|
||
if (this.response_max_length < 0) {
|
||
throw new Error(`Response max length cannot be less than 0`);
|
||
}
|
||
|
||
if (this.response_max_length > RESPONSE_BODY_LENGTH_MAX) {
|
||
throw new Error(`Response max length cannot be more than ${RESPONSE_BODY_LENGTH_MAX} bytes`);
|
||
}
|
||
}
|
||
|
||
// Validate JSON fields to prevent invalid JSON from being stored in database
|
||
if (this.kafkaProducerBrokers) {
|
||
try {
|
||
JSON.parse(this.kafkaProducerBrokers);
|
||
} catch (e) {
|
||
throw new Error(`Kafka Producer Brokers must be valid JSON: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
if (this.kafkaProducerSaslOptions) {
|
||
try {
|
||
JSON.parse(this.kafkaProducerSaslOptions);
|
||
} catch (e) {
|
||
throw new Error(`Kafka Producer SASL Options must be valid JSON: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
if (this.rabbitmqNodes) {
|
||
try {
|
||
JSON.parse(this.rabbitmqNodes);
|
||
} catch (e) {
|
||
throw new Error(`RabbitMQ Nodes must be valid JSON: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
if (this.conditions) {
|
||
try {
|
||
JSON.parse(this.conditions);
|
||
} catch (e) {
|
||
throw new Error(`Conditions must be valid JSON: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
if (this.headers) {
|
||
try {
|
||
JSON.parse(this.headers);
|
||
} catch (e) {
|
||
throw new Error(`Headers must be valid JSON: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
if (this.accepted_statuscodes_json) {
|
||
try {
|
||
JSON.parse(this.accepted_statuscodes_json);
|
||
} catch (e) {
|
||
throw new Error(`Accepted status codes must be valid JSON: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
if (this.type === "ping") {
|
||
// ping parameters validation
|
||
if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) {
|
||
throw new Error(
|
||
`Packet size must be between ${PING_PACKET_SIZE_MIN} and ${PING_PACKET_SIZE_MAX} (default: ${PING_PACKET_SIZE_DEFAULT})`
|
||
);
|
||
}
|
||
|
||
if (
|
||
this.ping_per_request_timeout &&
|
||
(this.ping_per_request_timeout < PING_PER_REQUEST_TIMEOUT_MIN ||
|
||
this.ping_per_request_timeout > PING_PER_REQUEST_TIMEOUT_MAX)
|
||
) {
|
||
throw new Error(
|
||
`Per-ping timeout must be between ${PING_PER_REQUEST_TIMEOUT_MIN} and ${PING_PER_REQUEST_TIMEOUT_MAX} seconds (default: ${PING_PER_REQUEST_TIMEOUT_DEFAULT})`
|
||
);
|
||
}
|
||
|
||
if (this.ping_count && (this.ping_count < PING_COUNT_MIN || this.ping_count > PING_COUNT_MAX)) {
|
||
throw new Error(
|
||
`Echo requests count must be between ${PING_COUNT_MIN} and ${PING_COUNT_MAX} (default: ${PING_COUNT_DEFAULT})`
|
||
);
|
||
}
|
||
|
||
if (this.timeout) {
|
||
const pingGlobalTimeout = Math.round(Number(this.timeout));
|
||
|
||
if (
|
||
pingGlobalTimeout < this.ping_per_request_timeout ||
|
||
pingGlobalTimeout < PING_GLOBAL_TIMEOUT_MIN ||
|
||
pingGlobalTimeout > PING_GLOBAL_TIMEOUT_MAX
|
||
) {
|
||
throw new Error(
|
||
`Timeout must be between ${PING_GLOBAL_TIMEOUT_MIN} and ${PING_GLOBAL_TIMEOUT_MAX} seconds (default: ${PING_GLOBAL_TIMEOUT_DEFAULT})`
|
||
);
|
||
}
|
||
|
||
this.timeout = pingGlobalTimeout;
|
||
}
|
||
}
|
||
|
||
if (this.type === "real-browser") {
|
||
// screenshot_delay validation
|
||
if (this.screenshot_delay !== undefined && this.screenshot_delay !== null) {
|
||
const delay = Number(this.screenshot_delay);
|
||
if (isNaN(delay) || delay < 0) {
|
||
throw new Error("Screenshot delay must be a non-negative number");
|
||
}
|
||
|
||
// Must not exceed 0.8 * timeout (page.goto timeout is interval * 1000 * 0.8)
|
||
const maxDelayFromTimeout = this.interval * 1000 * 0.8;
|
||
if (delay >= maxDelayFromTimeout) {
|
||
throw new Error(`Screenshot delay must be less than ${maxDelayFromTimeout}ms (0.8 × interval)`);
|
||
}
|
||
|
||
// Must not exceed 0.5 * interval to prevent blocking next check
|
||
const maxDelayFromInterval = this.interval * 1000 * 0.5;
|
||
if (delay >= maxDelayFromInterval) {
|
||
throw new Error(`Screenshot delay must be less than ${maxDelayFromInterval}ms (0.5 × interval)`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (this.type === "mongodb" && this.databaseQuery) {
|
||
// Validate that databaseQuery is valid JSON
|
||
try {
|
||
JSON.parse(this.databaseQuery);
|
||
} catch (error) {
|
||
throw new Error(`Invalid JSON in database query: ${error.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Gets monitor notification of multiple monitor
|
||
* @param {Array} monitorIDs IDs of monitor to get
|
||
* @returns {Promise<LooseObject<any>>} object
|
||
*/
|
||
static async getMonitorNotification(monitorIDs) {
|
||
return await R.getAll(
|
||
`
|
||
SELECT monitor_notification.monitor_id, monitor_notification.notification_id
|
||
FROM monitor_notification
|
||
WHERE monitor_notification.monitor_id IN (${monitorIDs.map((_) => "?").join(",")})
|
||
`,
|
||
monitorIDs
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Gets monitor tags of multiple monitor
|
||
* @param {Array} monitorIDs IDs of monitor to get
|
||
* @returns {Promise<LooseObject<any>>} object
|
||
*/
|
||
static async getMonitorTag(monitorIDs) {
|
||
return await R.getAll(
|
||
`
|
||
SELECT monitor_tag.monitor_id, monitor_tag.tag_id, monitor_tag.value, tag.name, tag.color
|
||
FROM monitor_tag
|
||
JOIN tag ON monitor_tag.tag_id = tag.id
|
||
WHERE monitor_tag.monitor_id IN (${monitorIDs.map((_) => "?").join(",")})
|
||
`,
|
||
monitorIDs
|
||
);
|
||
}
|
||
|
||
/**
|
||
* prepare preloaded data for efficient access
|
||
* @param {Array} monitorData IDs & active field of monitor to get
|
||
* @returns {Promise<LooseObject<any>>} object
|
||
*/
|
||
static async preparePreloadData(monitorData) {
|
||
const notificationsMap = new Map();
|
||
const tagsMap = new Map();
|
||
const maintenanceStatusMap = new Map();
|
||
const childrenIDsMap = new Map();
|
||
const activeStatusMap = new Map();
|
||
const forceInactiveMap = new Map();
|
||
const pathsMap = new Map();
|
||
|
||
if (monitorData.length > 0) {
|
||
const monitorIDs = monitorData.map((monitor) => monitor.id);
|
||
const notifications = await Monitor.getMonitorNotification(monitorIDs);
|
||
const tags = await Monitor.getMonitorTag(monitorIDs);
|
||
const maintenanceStatuses = await Promise.all(
|
||
monitorData.map((monitor) => Monitor.isUnderMaintenance(monitor.id))
|
||
);
|
||
const childrenIDs = await Promise.all(monitorData.map((monitor) => Monitor.getAllChildrenIDs(monitor.id)));
|
||
const activeStatuses = await Promise.all(
|
||
monitorData.map((monitor) => Monitor.isActive(monitor.id, monitor.active))
|
||
);
|
||
const forceInactiveStatuses = await Promise.all(
|
||
monitorData.map((monitor) => Monitor.isParentActive(monitor.id))
|
||
);
|
||
const paths = await Promise.all(monitorData.map((monitor) => Monitor.getAllPath(monitor.id, monitor.name)));
|
||
|
||
notifications.forEach((row) => {
|
||
if (!notificationsMap.has(row.monitor_id)) {
|
||
notificationsMap.set(row.monitor_id, {});
|
||
}
|
||
notificationsMap.get(row.monitor_id)[row.notification_id] = true;
|
||
});
|
||
|
||
tags.forEach((row) => {
|
||
if (!tagsMap.has(row.monitor_id)) {
|
||
tagsMap.set(row.monitor_id, []);
|
||
}
|
||
tagsMap.get(row.monitor_id).push({
|
||
tag_id: row.tag_id,
|
||
monitor_id: row.monitor_id,
|
||
value: row.value,
|
||
name: row.name,
|
||
color: row.color,
|
||
});
|
||
});
|
||
|
||
monitorData.forEach((monitor, index) => {
|
||
maintenanceStatusMap.set(monitor.id, maintenanceStatuses[index]);
|
||
});
|
||
|
||
monitorData.forEach((monitor, index) => {
|
||
childrenIDsMap.set(monitor.id, childrenIDs[index]);
|
||
});
|
||
|
||
monitorData.forEach((monitor, index) => {
|
||
activeStatusMap.set(monitor.id, activeStatuses[index]);
|
||
});
|
||
|
||
monitorData.forEach((monitor, index) => {
|
||
forceInactiveMap.set(monitor.id, !forceInactiveStatuses[index]);
|
||
});
|
||
|
||
monitorData.forEach((monitor, index) => {
|
||
pathsMap.set(monitor.id, paths[index]);
|
||
});
|
||
}
|
||
|
||
return {
|
||
notifications: notificationsMap,
|
||
tags: tagsMap,
|
||
maintenanceStatus: maintenanceStatusMap,
|
||
childrenIDs: childrenIDsMap,
|
||
activeStatus: activeStatusMap,
|
||
forceInactive: forceInactiveMap,
|
||
paths: pathsMap,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Gets Parent of the monitor
|
||
* @param {number} monitorID ID of monitor to get
|
||
* @returns {Promise<LooseObject<any>>} Parent
|
||
*/
|
||
static async getParent(monitorID) {
|
||
return await R.getRow(
|
||
`
|
||
SELECT parent.* FROM monitor parent
|
||
LEFT JOIN monitor child
|
||
ON child.parent = parent.id
|
||
WHERE child.id = ?
|
||
`,
|
||
[monitorID]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Gets all Children of the monitor
|
||
* @param {number} monitorID ID of monitor to get
|
||
* @returns {Promise<LooseObject<any>[]>} Children
|
||
*/
|
||
static async getChildren(monitorID) {
|
||
return await R.getAll(
|
||
`
|
||
SELECT * FROM monitor
|
||
WHERE parent = ?
|
||
`,
|
||
[monitorID]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Gets the full path
|
||
* @param {number} monitorID ID of the monitor to get
|
||
* @param {string} name of the monitor to get
|
||
* @returns {Promise<string[]>} Full path (includes groups and the name) of the monitor
|
||
*/
|
||
static async getAllPath(monitorID, name) {
|
||
const path = [name];
|
||
|
||
if (this.parent === null) {
|
||
return path;
|
||
}
|
||
|
||
let parent = await Monitor.getParent(monitorID);
|
||
while (parent !== null) {
|
||
path.unshift(parent.name);
|
||
parent = await Monitor.getParent(parent.id);
|
||
}
|
||
|
||
return path;
|
||
}
|
||
|
||
/**
|
||
* Gets recursive all child ids
|
||
* @param {number} monitorID ID of the monitor to get
|
||
* @returns {Promise<Array>} IDs of all children
|
||
*/
|
||
static async getAllChildrenIDs(monitorID) {
|
||
const childs = await Monitor.getChildren(monitorID);
|
||
|
||
if (childs === null) {
|
||
return [];
|
||
}
|
||
|
||
let childrenIDs = [];
|
||
|
||
for (const child of childs) {
|
||
childrenIDs.push(child.id);
|
||
childrenIDs = childrenIDs.concat(await Monitor.getAllChildrenIDs(child.id));
|
||
}
|
||
|
||
return childrenIDs;
|
||
}
|
||
|
||
/**
|
||
* Unlinks all children of the group monitor
|
||
* @param {number} groupID ID of group to remove children of
|
||
* @returns {Promise<void>}
|
||
*/
|
||
static async unlinkAllChildren(groupID) {
|
||
return await R.exec("UPDATE `monitor` SET parent = ? WHERE parent = ? ", [null, groupID]);
|
||
}
|
||
|
||
/**
|
||
* Delete a monitor from the system
|
||
* @param {number} monitorID ID of the monitor to delete
|
||
* @param {number} userID ID of the user who owns the monitor
|
||
* @returns {Promise<void>}
|
||
*/
|
||
static async deleteMonitor(monitorID, userID) {
|
||
const server = UptimeKumaServer.getInstance();
|
||
|
||
// Stop the monitor if it's running
|
||
if (monitorID in server.monitorList) {
|
||
await server.monitorList[monitorID].stop();
|
||
delete server.monitorList[monitorID];
|
||
}
|
||
|
||
// Delete from database
|
||
await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [monitorID, userID]);
|
||
}
|
||
|
||
/**
|
||
* Recursively delete a monitor and all its descendants
|
||
* @param {number} monitorID ID of the monitor to delete
|
||
* @param {number} userID ID of the user who owns the monitor
|
||
* @returns {Promise<void>}
|
||
*/
|
||
static async deleteMonitorRecursively(monitorID, userID) {
|
||
// Check if this monitor is a group
|
||
const monitor = await R.findOne("monitor", " id = ? AND user_id = ? ", [monitorID, userID]);
|
||
|
||
if (monitor && monitor.type === "group") {
|
||
// Get all children and delete them recursively
|
||
const children = await Monitor.getChildren(monitorID);
|
||
if (children && children.length > 0) {
|
||
for (const child of children) {
|
||
await Monitor.deleteMonitorRecursively(child.id, userID);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Delete the monitor itself
|
||
await Monitor.deleteMonitor(monitorID, userID);
|
||
}
|
||
|
||
/**
|
||
* Checks recursive if parent (ancestors) are active
|
||
* @param {number} monitorID ID of the monitor to get
|
||
* @returns {Promise<boolean>} Is the parent monitor active?
|
||
*/
|
||
static async isParentActive(monitorID) {
|
||
const parent = await Monitor.getParent(monitorID);
|
||
|
||
if (parent === null) {
|
||
return true;
|
||
}
|
||
|
||
const parentActive = await Monitor.isParentActive(parent.id);
|
||
return parent.active && parentActive;
|
||
}
|
||
|
||
/**
|
||
* Obtains a new Oidc Token
|
||
* @returns {Promise<object>} OAuthProvider client
|
||
*/
|
||
async makeOidcTokenClientCredentialsRequest() {
|
||
log.debug("monitor", `[${this.name}] The oauth access-token undefined or expired. Requesting a new token`);
|
||
const oAuthAccessToken = await getOidcTokenClientCredentials(
|
||
this.oauth_token_url,
|
||
this.oauth_client_id,
|
||
this.oauth_client_secret,
|
||
this.oauth_scopes,
|
||
this.oauth_audience,
|
||
this.oauth_auth_method
|
||
);
|
||
if (this.oauthAccessToken?.expires_at) {
|
||
log.debug(
|
||
"monitor",
|
||
`[${this.name}] Obtained oauth access-token. Expires at ${new Date(this.oauthAccessToken?.expires_at * 1000)}`
|
||
);
|
||
} else {
|
||
log.debug("monitor", `[${this.name}] Obtained oauth access-token. Time until expiry was not provided`);
|
||
}
|
||
|
||
return oAuthAccessToken;
|
||
}
|
||
|
||
/**
|
||
* Store TLS certificate information and check for expiry
|
||
* @param {object} tlsInfo Information about the TLS connection
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async handleTlsInfo(tlsInfo) {
|
||
await this.updateTlsInfo(tlsInfo);
|
||
this.prometheus?.update(null, tlsInfo, null);
|
||
|
||
if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) {
|
||
log.debug("monitor", `[${this.name}] call checkCertExpiryNotifications`);
|
||
await this.checkCertExpiryNotifications(tlsInfo);
|
||
}
|
||
}
|
||
}
|
||
|
||
module.exports = Monitor;
|