mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-08-09 12:21:22 +08:00
Merge branch 'master' into 2.0.X
# Conflicts: # package-lock.json # server/database.js # server/util-server.js
This commit is contained in:
@@ -28,6 +28,8 @@ class Database {
|
||||
|
||||
static sqlitePath;
|
||||
|
||||
static dockerTLSDir;
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
@@ -79,6 +81,10 @@ class Database {
|
||||
"patch-add-invert-keyword.sql": true,
|
||||
"patch-added-json-query.sql": true,
|
||||
"patch-added-kafka-producer.sql": true,
|
||||
"patch-add-certificate-expiry-status-page.sql": true,
|
||||
"patch-monitor-oauth-cc.sql": true,
|
||||
"patch-add-timeout-monitor.sql": true,
|
||||
"patch-add-gamedig-given-port.sql": true,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -101,23 +107,28 @@ class Database {
|
||||
// Data Directory (must be end with "/")
|
||||
Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/";
|
||||
|
||||
Database.sqlitePath = Database.dataDir + "kuma.db";
|
||||
Database.sqlitePath = path.join(Database.dataDir, "kuma.db");
|
||||
if (! fs.existsSync(Database.dataDir)) {
|
||||
fs.mkdirSync(Database.dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
Database.uploadDir = Database.dataDir + "upload/";
|
||||
Database.uploadDir = path.join(Database.dataDir, "upload/");
|
||||
|
||||
if (! fs.existsSync(Database.uploadDir)) {
|
||||
fs.mkdirSync(Database.uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create screenshot dir
|
||||
Database.screenshotDir = Database.dataDir + "screenshots/";
|
||||
Database.screenshotDir = path.join(Database.dataDir, "screenshots/");
|
||||
if (! fs.existsSync(Database.screenshotDir)) {
|
||||
fs.mkdirSync(Database.screenshotDir, { recursive: true });
|
||||
}
|
||||
|
||||
Database.dockerTLSDir = path.join(Database.dataDir, "docker-tls/");
|
||||
if (! fs.existsSync(Database.dockerTLSDir)) {
|
||||
fs.mkdirSync(Database.dockerTLSDir, { recursive: true });
|
||||
}
|
||||
|
||||
log.info("db", `Data Dir: ${Database.dataDir}`);
|
||||
}
|
||||
|
||||
|
@@ -2,8 +2,16 @@ const axios = require("axios");
|
||||
const { R } = require("redbean-node");
|
||||
const version = require("../package.json").version;
|
||||
const https = require("https");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const Database = require("./database");
|
||||
|
||||
class DockerHost {
|
||||
|
||||
static CertificateFileNameCA = "ca.pem";
|
||||
static CertificateFileNameCert = "cert.pem";
|
||||
static CertificateFileNameKey = "key.pem";
|
||||
|
||||
/**
|
||||
* Save a docker host
|
||||
* @param {Object} dockerHost Docker host to save
|
||||
@@ -66,10 +74,6 @@ class DockerHost {
|
||||
"Accept": "*/*",
|
||||
"User-Agent": "Uptime-Kuma/" + version
|
||||
},
|
||||
httpsAgent: new https.Agent({
|
||||
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
|
||||
rejectUnauthorized: false,
|
||||
}),
|
||||
};
|
||||
|
||||
if (dockerHost.dockerType === "socket") {
|
||||
@@ -77,6 +81,7 @@ class DockerHost {
|
||||
} else if (dockerHost.dockerType === "tcp") {
|
||||
options.baseURL = DockerHost.patchDockerURL(dockerHost.dockerDaemon);
|
||||
}
|
||||
options.httpsAgent = new https.Agent(DockerHost.getHttpsAgentOptions(dockerHost.dockerType, options.baseURL));
|
||||
|
||||
let res = await axios.request(options);
|
||||
|
||||
@@ -111,6 +116,53 @@ class DockerHost {
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns HTTPS agent options with client side TLS parameters if certificate files
|
||||
* for the given host are available under a predefined directory path.
|
||||
*
|
||||
* The base path where certificates are looked for can be set with the
|
||||
* 'DOCKER_TLS_DIR_PATH' environmental variable or defaults to 'data/docker-tls/'.
|
||||
*
|
||||
* If a directory in this path exists with a name matching the FQDN of the docker host
|
||||
* (e.g. the FQDN of 'https://example.com:2376' is 'example.com' so the directory
|
||||
* 'data/docker-tls/example.com/' would be searched for certificate files),
|
||||
* then 'ca.pem', 'key.pem' and 'cert.pem' files are included in the agent options.
|
||||
* File names can also be overridden via 'DOCKER_TLS_FILE_NAME_(CA|KEY|CERT)'.
|
||||
*
|
||||
* @param {String} dockerType i.e. "tcp" or "socket"
|
||||
* @param {String} url The docker host URL rewritten to https://
|
||||
* @return {Object}
|
||||
* */
|
||||
static getHttpsAgentOptions(dockerType, url) {
|
||||
let baseOptions = {
|
||||
maxCachedSessions: 0,
|
||||
rejectUnauthorized: true
|
||||
};
|
||||
let certOptions = {};
|
||||
|
||||
let dirName = (new URL(url)).hostname;
|
||||
|
||||
let caPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameCA);
|
||||
let certPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameCert);
|
||||
let keyPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameKey);
|
||||
|
||||
if (dockerType === "tcp" && fs.existsSync(caPath) && fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
let ca = fs.readFileSync(caPath);
|
||||
let key = fs.readFileSync(keyPath);
|
||||
let cert = fs.readFileSync(certPath);
|
||||
certOptions = {
|
||||
ca,
|
||||
key,
|
||||
cert
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...baseOptions,
|
||||
...certOptions
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
@@ -9,12 +9,12 @@ class Group extends BeanModel {
|
||||
* @param {boolean} [showTags=false] Should the JSON include monitor tags
|
||||
* @returns {Object}
|
||||
*/
|
||||
async toPublicJSON(showTags = false) {
|
||||
async toPublicJSON(showTags = false, certExpiry = false) {
|
||||
let monitorBeanList = await this.getMonitorList();
|
||||
let monitorList = [];
|
||||
|
||||
for (let bean of monitorBeanList) {
|
||||
monitorList.push(await bean.toPublicJSON(showTags));
|
||||
monitorList.push(await bean.toPublicJSON(showTags, certExpiry));
|
||||
}
|
||||
|
||||
return {
|
||||
|
@@ -6,7 +6,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger, MAX_INTERVA
|
||||
SQL_DATETIME_FORMAT
|
||||
} = require("../../src/util");
|
||||
const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery,
|
||||
redisPingAsync, mongodbPing, kafkaProducerAsync
|
||||
redisPingAsync, mongodbPing, kafkaProducerAsync, getOidcTokenClientCredentials,
|
||||
} = require("../util-server");
|
||||
const { R } = require("redbean-node");
|
||||
const { BeanModel } = require("redbean-node/dist/bean-model");
|
||||
@@ -38,11 +38,12 @@ class Monitor extends BeanModel {
|
||||
* Only show necessary data to public
|
||||
* @returns {Object}
|
||||
*/
|
||||
async toPublicJSON(showTags = false) {
|
||||
async toPublicJSON(showTags = false, certExpiry = false) {
|
||||
let obj = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
sendUrl: this.sendUrl,
|
||||
type: this.type,
|
||||
};
|
||||
|
||||
if (this.sendUrl) {
|
||||
@@ -52,6 +53,13 @@ class Monitor extends BeanModel {
|
||||
if (showTags) {
|
||||
obj.tags = await this.getTags();
|
||||
}
|
||||
|
||||
if (certExpiry && this.type === "http") {
|
||||
const { certExpiryDaysRemaining, validCert } = await this.getCertExpiry(this.id);
|
||||
obj.certExpiryDaysRemaining = certExpiryDaysRemaining;
|
||||
obj.validCert = validCert;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
@@ -95,6 +103,7 @@ class Monitor extends BeanModel {
|
||||
active: await this.isActive(),
|
||||
forceInactive: !await Monitor.isParentActive(this.id),
|
||||
type: this.type,
|
||||
timeout: this.timeout,
|
||||
interval: this.interval,
|
||||
retryInterval: this.retryInterval,
|
||||
resendInterval: this.resendInterval,
|
||||
@@ -127,6 +136,7 @@ class Monitor extends BeanModel {
|
||||
radiusCalledStationId: this.radiusCalledStationId,
|
||||
radiusCallingStationId: this.radiusCallingStationId,
|
||||
game: this.game,
|
||||
gamedigGivenPortOnly: this.getGameDigGivenPortOnly(),
|
||||
httpBodyEncoding: this.httpBodyEncoding,
|
||||
jsonPath: this.jsonPath,
|
||||
expectedValue: this.expectedValue,
|
||||
@@ -147,6 +157,11 @@ class Monitor extends BeanModel {
|
||||
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_auth_method: this.oauth_auth_method,
|
||||
pushToken: this.pushToken,
|
||||
databaseConnectionString: this.databaseConnectionString,
|
||||
radiusUsername: this.radiusUsername,
|
||||
@@ -185,6 +200,31 @@ class Monitor extends BeanModel {
|
||||
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>>}
|
||||
*/
|
||||
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
|
||||
@@ -242,6 +282,10 @@ class Monitor extends BeanModel {
|
||||
return JSON.parse(this.accepted_statuscodes_json);
|
||||
}
|
||||
|
||||
getGameDigGivenPortOnly() {
|
||||
return Boolean(this.gamedigGivenPortOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitor
|
||||
* @param {Server} io Socket server instance
|
||||
@@ -314,7 +358,10 @@ class Monitor extends BeanModel {
|
||||
const lastBeat = await Monitor.getPreviousHeartbeat(child.id);
|
||||
|
||||
// Only change state if the monitor is in worse conditions then the ones before
|
||||
if (bean.status === UP && (lastBeat.status === PENDING || lastBeat.status === DOWN)) {
|
||||
// lastBeat.status could be null
|
||||
if (!lastBeat) {
|
||||
bean.status = PENDING;
|
||||
} else if (bean.status === UP && (lastBeat.status === PENDING || lastBeat.status === DOWN)) {
|
||||
bean.status = lastBeat.status;
|
||||
} else if (bean.status === PENDING && lastBeat.status === DOWN) {
|
||||
bean.status = lastBeat.status;
|
||||
@@ -342,6 +389,24 @@ class Monitor extends BeanModel {
|
||||
};
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
log.debug("monitor", `[${this.name}] The oauth access-token undefined or expired. Requesting a new one`);
|
||||
this.oauthAccessToken = await getOidcTokenClientCredentials(this.oauth_token_url, this.oauth_client_id, this.oauth_client_secret, this.oauth_scopes, this.oauth_auth_method);
|
||||
log.debug("monitor", `[${this.name}] Obtained oauth access-token. Expires at ${new Date(this.oauthAccessToken.expires_at * 1000)}`);
|
||||
}
|
||||
oauth2AuthHeader = {
|
||||
"Authorization": this.oauthAccessToken.token_type + " " + this.oauthAccessToken.access_token,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error("The oauth config is invalid. " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const httpsAgentOptions = {
|
||||
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
|
||||
rejectUnauthorized: !this.getIgnoreTls(),
|
||||
@@ -370,12 +435,13 @@ class Monitor extends BeanModel {
|
||||
const options = {
|
||||
url: this.url,
|
||||
method: (this.method || "get").toLowerCase(),
|
||||
timeout: this.interval * 1000 * 0.8,
|
||||
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",
|
||||
"User-Agent": "Uptime-Kuma/" + version,
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
...(basicAuthHeader),
|
||||
...(oauth2AuthHeader),
|
||||
...(this.headers ? JSON.parse(this.headers) : {})
|
||||
},
|
||||
maxRedirects: this.maxredirects,
|
||||
@@ -589,7 +655,7 @@ class Monitor extends BeanModel {
|
||||
}
|
||||
|
||||
let res = await axios.get(steamApiUrl, {
|
||||
timeout: this.interval * 1000 * 0.8,
|
||||
timeout: this.timeout * 1000,
|
||||
headers: {
|
||||
"Accept": "*/*",
|
||||
"User-Agent": "Uptime-Kuma/" + version,
|
||||
@@ -627,7 +693,7 @@ class Monitor extends BeanModel {
|
||||
type: this.game,
|
||||
host: this.hostname,
|
||||
port: this.port,
|
||||
givenPortOnly: true,
|
||||
givenPortOnly: this.getGameDigGivenPortOnly(),
|
||||
});
|
||||
|
||||
bean.msg = state.name;
|
||||
@@ -661,6 +727,9 @@ class Monitor extends BeanModel {
|
||||
options.socketPath = dockerHost._dockerDaemon;
|
||||
} else if (dockerHost._dockerType === "tcp") {
|
||||
options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon);
|
||||
options.httpsAgent = CacheableDnsHttpAgent.getHttpsAgent(
|
||||
DockerHost.getHttpsAgentOptions(dockerHost._dockerType, options.baseURL)
|
||||
);
|
||||
}
|
||||
|
||||
log.debug("monitor", `[${this.name}] Axios Request`);
|
||||
@@ -760,29 +829,19 @@ class Monitor extends BeanModel {
|
||||
port = this.port;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await radius(
|
||||
this.hostname,
|
||||
this.radiusUsername,
|
||||
this.radiusPassword,
|
||||
this.radiusCalledStationId,
|
||||
this.radiusCallingStationId,
|
||||
this.radiusSecret,
|
||||
port,
|
||||
this.interval * 1000 * 0.8,
|
||||
);
|
||||
if (resp.code) {
|
||||
bean.msg = resp.code;
|
||||
}
|
||||
bean.status = UP;
|
||||
} catch (error) {
|
||||
bean.status = DOWN;
|
||||
if (error.response?.code) {
|
||||
bean.msg = error.response.code;
|
||||
} else {
|
||||
bean.msg = error.message;
|
||||
}
|
||||
}
|
||||
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 === "redis") {
|
||||
let startTime = dayjs().valueOf();
|
||||
|
@@ -90,6 +90,8 @@ class StatusPage extends BeanModel {
|
||||
* @param {StatusPage} statusPage
|
||||
*/
|
||||
static async getStatusPageData(statusPage) {
|
||||
const config = await statusPage.toPublicJSON();
|
||||
|
||||
// Incident
|
||||
let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [
|
||||
statusPage.id,
|
||||
@@ -110,13 +112,13 @@ class StatusPage extends BeanModel {
|
||||
]);
|
||||
|
||||
for (let groupBean of list) {
|
||||
let monitorGroup = await groupBean.toPublicJSON(showTags);
|
||||
let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry);
|
||||
publicGroupList.push(monitorGroup);
|
||||
}
|
||||
|
||||
// Response
|
||||
return {
|
||||
config: await statusPage.toPublicJSON(),
|
||||
config,
|
||||
incident,
|
||||
publicGroupList,
|
||||
maintenanceList,
|
||||
@@ -234,6 +236,7 @@ class StatusPage extends BeanModel {
|
||||
footerText: this.footer_text,
|
||||
showPoweredBy: !!this.show_powered_by,
|
||||
googleAnalyticsId: this.google_analytics_tag_id,
|
||||
showCertificateExpiry: !!this.show_certificate_expiry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,6 +258,7 @@ class StatusPage extends BeanModel {
|
||||
footerText: this.footer_text,
|
||||
showPoweredBy: !!this.show_powered_by,
|
||||
googleAnalyticsId: this.google_analytics_tag_id,
|
||||
showCertificateExpiry: !!this.show_certificate_expiry,
|
||||
};
|
||||
}
|
||||
|
||||
|
98
server/notification-providers/flashduty.js
Normal file
98
server/notification-providers/flashduty.js
Normal file
@@ -0,0 +1,98 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util");
|
||||
const { setting } = require("../util-server");
|
||||
const successMessage = "Sent Successfully.";
|
||||
|
||||
class FlashDuty extends NotificationProvider {
|
||||
name = "FlashDuty";
|
||||
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
try {
|
||||
if (heartbeatJSON == null) {
|
||||
const title = "Uptime Kuma Alert";
|
||||
const monitor = {
|
||||
type: "ping",
|
||||
url: msg,
|
||||
name: "https://flashcat.cloud"
|
||||
};
|
||||
return this.postNotification(notification, title, msg, monitor);
|
||||
}
|
||||
|
||||
if (heartbeatJSON.status === UP) {
|
||||
const title = "Uptime Kuma Monitor ✅ Up";
|
||||
|
||||
return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, "Ok");
|
||||
}
|
||||
|
||||
if (heartbeatJSON.status === DOWN) {
|
||||
const title = "Uptime Kuma Monitor 🔴 Down";
|
||||
return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, notification.flashdutySeverity);
|
||||
}
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Generate a monitor url from the monitors infomation
|
||||
* @param {Object} monitorInfo Monitor details
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
|
||||
genMonitorUrl(monitorInfo) {
|
||||
if (monitorInfo.type === "port" && monitorInfo.port) {
|
||||
return monitorInfo.hostname + ":" + monitorInfo.port;
|
||||
}
|
||||
if (monitorInfo.hostname != null) {
|
||||
return monitorInfo.hostname;
|
||||
}
|
||||
return monitorInfo.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the message
|
||||
* @param {BeanModel} notification Message title
|
||||
* @param {string} title Message
|
||||
* @param {string} body Message
|
||||
* @param {Object} monitorInfo Monitor details
|
||||
* @param {string} eventStatus Monitor status (Info, Warning, Critical, Ok)
|
||||
* @returns {string}
|
||||
*/
|
||||
async postNotification(notification, title, body, monitorInfo, eventStatus) {
|
||||
const options = {
|
||||
method: "POST",
|
||||
url: "https://api.flashcat.cloud/event/push/alert/standard?integration_key=" + notification.flashdutyIntegrationKey,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
data: {
|
||||
description: `[${title}] [${monitorInfo.name}] ${body}`,
|
||||
title,
|
||||
event_status: eventStatus || "Info",
|
||||
alert_key: String(monitorInfo.id) || Math.random().toString(36).substring(7),
|
||||
labels: monitorInfo?.tags?.reduce((acc, item) => ({ ...acc,
|
||||
[item.name]: item.value
|
||||
}), { resource: this.genMonitorUrl(monitorInfo) }),
|
||||
}
|
||||
};
|
||||
|
||||
const baseURL = await setting("primaryBaseURL");
|
||||
if (baseURL && monitorInfo) {
|
||||
options.client = "Uptime Kuma";
|
||||
options.client_url = baseURL + getMonitorRelativeURL(monitorInfo.id);
|
||||
}
|
||||
|
||||
let result = await axios.request(options);
|
||||
if (result.status == null) {
|
||||
throw new Error("FlashDuty notification failed with invalid response!");
|
||||
}
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
throw new Error("FlashDuty notification failed with status code " + result.status);
|
||||
}
|
||||
if (result.statusText != null) {
|
||||
return "FlashDuty notification succeed: " + result.statusText;
|
||||
}
|
||||
|
||||
return successMessage;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FlashDuty;
|
119
server/notification-providers/nostr.js
Normal file
119
server/notification-providers/nostr.js
Normal file
@@ -0,0 +1,119 @@
|
||||
const { log } = require("../../src/util");
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const {
|
||||
relayInit,
|
||||
getPublicKey,
|
||||
getEventHash,
|
||||
getSignature,
|
||||
nip04,
|
||||
nip19
|
||||
} = require("nostr-tools");
|
||||
|
||||
// polyfills for node versions
|
||||
const semver = require("semver");
|
||||
const nodeVersion = process.version;
|
||||
if (semver.lt(nodeVersion, "16.0.0")) {
|
||||
log.warn("monitor", "Node <= 16 is unsupported for nostr, sorry :(");
|
||||
} else if (semver.lt(nodeVersion, "18.0.0")) {
|
||||
// polyfills for node 16
|
||||
global.crypto = require("crypto");
|
||||
global.WebSocket = require("isomorphic-ws");
|
||||
if (typeof crypto !== "undefined" && !crypto.subtle && crypto.webcrypto) {
|
||||
crypto.subtle = crypto.webcrypto.subtle;
|
||||
}
|
||||
} else if (semver.lt(nodeVersion, "20.0.0")) {
|
||||
// polyfills for node 18
|
||||
global.crypto = require("crypto");
|
||||
global.WebSocket = require("isomorphic-ws");
|
||||
} else {
|
||||
// polyfills for node 20
|
||||
global.WebSocket = require("isomorphic-ws");
|
||||
}
|
||||
|
||||
class Nostr extends NotificationProvider {
|
||||
name = "nostr";
|
||||
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
// All DMs should have same timestamp
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
const senderPrivateKey = await this.getPrivateKey(notification.sender);
|
||||
const senderPublicKey = getPublicKey(senderPrivateKey);
|
||||
const recipientsPublicKeys = await this.getPublicKeys(notification.recipients);
|
||||
|
||||
// Create NIP-04 encrypted direct message event for each recipient
|
||||
const events = [];
|
||||
for (const recipientPublicKey of recipientsPublicKeys) {
|
||||
const ciphertext = await nip04.encrypt(senderPrivateKey, recipientPublicKey, msg);
|
||||
let event = {
|
||||
kind: 4,
|
||||
pubkey: senderPublicKey,
|
||||
created_at: createdAt,
|
||||
tags: [[ "p", recipientPublicKey ]],
|
||||
content: ciphertext,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = getSignature(event, senderPrivateKey);
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// Publish events to each relay
|
||||
const relays = notification.relays.split("\n");
|
||||
let successfulRelays = 0;
|
||||
|
||||
// Connect to each relay
|
||||
for (const relayUrl of relays) {
|
||||
const relay = relayInit(relayUrl);
|
||||
try {
|
||||
await relay.connect();
|
||||
successfulRelays++;
|
||||
|
||||
// Publish events
|
||||
for (const event of events) {
|
||||
relay.publish(event);
|
||||
}
|
||||
} catch (error) {
|
||||
continue;
|
||||
} finally {
|
||||
relay.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Report success or failure
|
||||
if (successfulRelays === 0) {
|
||||
throw Error("Failed to connect to any relays.");
|
||||
}
|
||||
return `${successfulRelays}/${relays.length} relays connected.`;
|
||||
}
|
||||
|
||||
async getPrivateKey(sender) {
|
||||
try {
|
||||
const senderDecodeResult = await nip19.decode(sender);
|
||||
const { data } = senderDecodeResult;
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get private key: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getPublicKeys(recipients) {
|
||||
const recipientsList = recipients.split("\n");
|
||||
const publicKeys = [];
|
||||
for (const recipient of recipientsList) {
|
||||
try {
|
||||
const recipientDecodeResult = await nip19.decode(recipient);
|
||||
const { type, data } = recipientDecodeResult;
|
||||
if (type === "npub") {
|
||||
publicKeys.push(data);
|
||||
} else {
|
||||
throw new Error("not an npub");
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Error decoding recipient: ${error}`);
|
||||
}
|
||||
}
|
||||
return publicKeys;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Nostr;
|
@@ -8,7 +8,9 @@ class PushDeer extends NotificationProvider {
|
||||
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
let okMsg = "Sent Successfully.";
|
||||
let pushdeerlink = "https://api2.pushdeer.com/message/push";
|
||||
let endpoint = "/message/push";
|
||||
let serverUrl = notification.pushdeerServer || "https://api2.pushdeer.com";
|
||||
let pushdeerlink = `${serverUrl.trim().replace(/\/*$/, "")}${endpoint}`;
|
||||
|
||||
let valid = msg != null && monitorJSON != null && heartbeatJSON != null;
|
||||
|
||||
|
@@ -21,11 +21,13 @@ const LineNotify = require("./notification-providers/linenotify");
|
||||
const LunaSea = require("./notification-providers/lunasea");
|
||||
const Matrix = require("./notification-providers/matrix");
|
||||
const Mattermost = require("./notification-providers/mattermost");
|
||||
const Nostr = require("./notification-providers/nostr");
|
||||
const Ntfy = require("./notification-providers/ntfy");
|
||||
const Octopush = require("./notification-providers/octopush");
|
||||
const OneBot = require("./notification-providers/onebot");
|
||||
const Opsgenie = require("./notification-providers/opsgenie");
|
||||
const PagerDuty = require("./notification-providers/pagerduty");
|
||||
const FlashDuty = require("./notification-providers/flashduty");
|
||||
const PagerTree = require("./notification-providers/pagertree");
|
||||
const PromoSMS = require("./notification-providers/promosms");
|
||||
const Pushbullet = require("./notification-providers/pushbullet");
|
||||
@@ -84,11 +86,13 @@ class Notification {
|
||||
new LunaSea(),
|
||||
new Matrix(),
|
||||
new Mattermost(),
|
||||
new Nostr(),
|
||||
new Ntfy(),
|
||||
new Octopush(),
|
||||
new OneBot(),
|
||||
new Opsgenie(),
|
||||
new PagerDuty(),
|
||||
new FlashDuty(),
|
||||
new PagerTree(),
|
||||
new PromoSMS(),
|
||||
new Pushbullet(),
|
||||
@@ -115,7 +119,6 @@ class Notification {
|
||||
new GoAlert(),
|
||||
new ZohoCliq()
|
||||
];
|
||||
|
||||
for (let item of list) {
|
||||
if (! item.name) {
|
||||
throw new Error("Notification provider without name");
|
||||
|
@@ -49,7 +49,7 @@ if (! process.env.NODE_ENV) {
|
||||
}
|
||||
|
||||
log.info("server", "Node Env: " + process.env.NODE_ENV);
|
||||
log.info("server", "Inside Container: " + process.env.UPTIME_KUMA_IS_CONTAINER === "1");
|
||||
log.info("server", "Inside Container: " + (process.env.UPTIME_KUMA_IS_CONTAINER === "1"));
|
||||
|
||||
log.info("server", "Importing Node libraries");
|
||||
const fs = require("fs");
|
||||
@@ -670,6 +670,10 @@ let needSetup = false;
|
||||
let notificationIDList = monitor.notificationIDList;
|
||||
delete monitor.notificationIDList;
|
||||
|
||||
// Ensure status code ranges are strings
|
||||
if (!monitor.accepted_statuscodes.every((code) => typeof code === "string")) {
|
||||
throw new Error("Accepted status codes are not all strings");
|
||||
}
|
||||
monitor.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
|
||||
delete monitor.accepted_statuscodes;
|
||||
|
||||
@@ -686,7 +690,10 @@ let needSetup = false;
|
||||
await updateMonitorNotification(bean.id, notificationIDList);
|
||||
|
||||
await server.sendMonitorList(socket);
|
||||
await startMonitor(socket.userID, bean.id);
|
||||
|
||||
if (monitor.active !== false) {
|
||||
await startMonitor(socket.userID, bean.id);
|
||||
}
|
||||
|
||||
log.info("monitor", `Added Monitor: ${monitor.id} User ID: ${socket.userID}`);
|
||||
|
||||
@@ -732,6 +739,11 @@ let needSetup = false;
|
||||
removeGroupChildren = true;
|
||||
}
|
||||
|
||||
// Ensure status code ranges are strings
|
||||
if (!monitor.accepted_statuscodes.every((code) => typeof code === "string")) {
|
||||
throw new Error("Accepted status codes are not all strings");
|
||||
}
|
||||
|
||||
bean.name = monitor.name;
|
||||
bean.description = monitor.description;
|
||||
bean.parent = monitor.parent;
|
||||
@@ -742,6 +754,12 @@ let needSetup = false;
|
||||
bean.headers = monitor.headers;
|
||||
bean.basic_auth_user = monitor.basic_auth_user;
|
||||
bean.basic_auth_pass = monitor.basic_auth_pass;
|
||||
bean.timeout = monitor.timeout;
|
||||
bean.oauth_client_id = monitor.oauth_client_id,
|
||||
bean.oauth_client_secret = monitor.oauth_client_secret,
|
||||
bean.oauth_auth_method = this.oauth_auth_method,
|
||||
bean.oauth_token_url = monitor.oauth_token_url,
|
||||
bean.oauth_scopes = monitor.oauth_scopes,
|
||||
bean.tlsCa = monitor.tlsCa;
|
||||
bean.tlsCert = monitor.tlsCert;
|
||||
bean.tlsKey = monitor.tlsKey;
|
||||
@@ -800,6 +818,7 @@ let needSetup = false;
|
||||
bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation;
|
||||
bean.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions);
|
||||
bean.kafkaProducerMessage = monitor.kafkaProducerMessage;
|
||||
bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly;
|
||||
|
||||
bean.validate();
|
||||
|
||||
@@ -1401,6 +1420,7 @@ let needSetup = false;
|
||||
|
||||
// Define default values
|
||||
let retryInterval = 0;
|
||||
let timeout = monitorListData[i].timeout || (monitorListData[i].interval * 0.8); // fallback to old value
|
||||
|
||||
/*
|
||||
Only replace the default value with the backup file data for the specific version, where it appears the first time
|
||||
@@ -1426,6 +1446,7 @@ let needSetup = false;
|
||||
basic_auth_pass: monitorListData[i].basic_auth_pass,
|
||||
authWorkstation: monitorListData[i].authWorkstation,
|
||||
authDomain: monitorListData[i].authDomain,
|
||||
timeout,
|
||||
interval: monitorListData[i].interval,
|
||||
retryInterval: retryInterval,
|
||||
resendInterval: monitorListData[i].resendInterval || 0,
|
||||
|
@@ -162,6 +162,7 @@ module.exports.statusPageSocketHandler = (socket) => {
|
||||
statusPage.footer_text = config.footerText;
|
||||
statusPage.custom_css = config.customCSS;
|
||||
statusPage.show_powered_by = config.showPoweredBy;
|
||||
statusPage.show_certificate_expiry = config.showCertificateExpiry;
|
||||
statusPage.modified_date = R.isoDateTime();
|
||||
statusPage.google_analytics_tag_id = config.googleAnalyticsId;
|
||||
|
||||
|
@@ -11,6 +11,7 @@ const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
|
||||
const { Settings } = require("./settings");
|
||||
const dayjs = require("dayjs");
|
||||
const childProcess = require("child_process");
|
||||
const path = require("path");
|
||||
// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead.
|
||||
|
||||
/**
|
||||
@@ -214,7 +215,7 @@ class UptimeKumaServer {
|
||||
* @param {boolean} outputToConsole Should the error also be output to console?
|
||||
*/
|
||||
static errorLog(error, outputToConsole = true) {
|
||||
const errorLogStream = fs.createWriteStream(Database.dataDir + "/error.log", {
|
||||
const errorLogStream = fs.createWriteStream(path.join(Database.dataDir, "/error.log"), {
|
||||
flags: "a"
|
||||
});
|
||||
|
||||
|
@@ -21,6 +21,8 @@ const grpc = require("@grpc/grpc-js");
|
||||
const protojs = require("protobufjs");
|
||||
const radiusClient = require("node-radius-client");
|
||||
const redis = require("redis");
|
||||
const oidc = require("openid-client");
|
||||
|
||||
const {
|
||||
dictionaries: {
|
||||
rfc2865: { file, attributes },
|
||||
@@ -55,6 +57,43 @@ exports.initJWTSecret = async () => {
|
||||
return jwtSecretBean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes a jwt and returns the payload portion without verifying the jqt.
|
||||
* @param {string} jwt The input jwt as a string
|
||||
* @returns {Object} Decoded jwt payload object
|
||||
*/
|
||||
exports.decodeJwt = (jwt) => {
|
||||
return JSON.parse(Buffer.from(jwt.split(".")[1], "base64").toString());
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a Access Token form a oidc/oauth2 provider
|
||||
* @param {string} tokenEndpoint The token URI form the auth service provider
|
||||
* @param {string} clientId The oidc/oauth application client id
|
||||
* @param {string} clientSecret The oidc/oauth application client secret
|
||||
* @param {string} scope The scope the for which the token should be issued for
|
||||
* @param {string} authMethod The method on how to sent the credentials. Default client_secret_basic
|
||||
* @returns {Promise<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
|
||||
@@ -489,6 +528,7 @@ exports.radius = function (
|
||||
host: hostname,
|
||||
hostPort: port,
|
||||
timeout: timeout,
|
||||
retries: 1,
|
||||
dictionaries: [ file ],
|
||||
});
|
||||
|
||||
@@ -500,6 +540,12 @@ exports.radius = function (
|
||||
[ attributes.CALLING_STATION_ID, callingStationId ],
|
||||
[ attributes.CALLED_STATION_ID, calledStationId ],
|
||||
],
|
||||
}).catch((error) => {
|
||||
if (error.response?.code) {
|
||||
throw Error(error.response.code);
|
||||
} else {
|
||||
throw Error(error.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -677,7 +723,6 @@ exports.checkCertificate = function (res) {
|
||||
* @param {number} status The status code to check
|
||||
* @param {string[]} acceptedCodes An array of accepted status codes
|
||||
* @returns {boolean} True if status code within range, false otherwise
|
||||
* @throws {Error} Will throw an error if the provided status code is not a valid range string or code string
|
||||
*/
|
||||
exports.checkStatusCode = function (status, acceptedCodes) {
|
||||
if (acceptedCodes == null || acceptedCodes.length === 0) {
|
||||
@@ -685,6 +730,11 @@ exports.checkStatusCode = function (status, acceptedCodes) {
|
||||
}
|
||||
|
||||
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]) {
|
||||
@@ -695,7 +745,8 @@ exports.checkStatusCode = function (status, acceptedCodes) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
throw new Error("Invalid status code range");
|
||||
log.error("monitor", `${codeRange} is not a valid status code range`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,3 +1058,13 @@ module.exports.grpcQuery = async (options) => {
|
||||
};
|
||||
|
||||
module.exports.prompt = (query) => new Promise((resolve) => rl.question(query, resolve));
|
||||
|
||||
// For unit test, export functions
|
||||
if (process.env.TEST_BACKEND) {
|
||||
module.exports.__test = {
|
||||
parseCertificateInfo,
|
||||
};
|
||||
module.exports.__getPrivateFunction = (functionName) => {
|
||||
return module.exports.__test[functionName];
|
||||
};
|
||||
}
|
||||
|
Reference in New Issue
Block a user