diff --git a/db/knex_migrations/2025-12-17-0000-add-globalping-monitor.js b/db/knex_migrations/2025-12-17-0000-add-globalping-monitor.js new file mode 100644 index 00000000000..b5ce4014087 --- /dev/null +++ b/db/knex_migrations/2025-12-17-0000-add-globalping-monitor.js @@ -0,0 +1,17 @@ +exports.up = function (knex) { + // Add new columns + return knex.schema.alterTable("monitor", function (table) { + table.string("subtype", 10).nullable(); + table.string("location", 255).nullable(); + table.string("protocol", 20).nullable(); + }); +}; + +exports.down = function (knex) { + // Drop columns + return knex.schema.alterTable("monitor", function (table) { + table.dropColumn("subtype"); + table.dropColumn("location"); + table.dropColumn("protocol"); + }); +}; diff --git a/package-lock.json b/package-lock.json index 7c4440169a3..30d102c60a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "feed": "^4.2.2", "form-data": "~4.0.0", "gamedig": "^5.0.1", + "globalping": "^0.2.0", "html-escaper": "^3.0.3", "http-cookie-agent": "~5.0.4", "http-graceful-shutdown": "~3.1.7", @@ -3555,6 +3556,18 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@hey-api/client-fetch": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.10.2.tgz", + "integrity": "sha512-AGiFYDx+y8VT1wlQ3EbzzZtfU8EfV+hLLRTtr8Y/tjYZaxIECwJagVZf24YzNbtEBXONFV50bwcU1wLVGXe1ow==", + "deprecated": "Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts.", + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "@hey-api/openapi-ts": "< 2" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.9.5", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", @@ -11373,6 +11386,14 @@ "which": "bin/which" } }, + "node_modules/globalping": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/globalping/-/globalping-0.2.0.tgz", + "integrity": "sha512-rxyvqXF/oz5yBGJHR58TEsCkcbc4WQVmdKC+uZ8LXfIt4KwTdTvbzkfwhhjuyUmuQr8U/b8m/ZicdrA8ZIroKw==", + "dependencies": { + "@hey-api/client-fetch": "^0.10.0" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", diff --git a/package.json b/package.json index ac693d8d8b8..ea402f7f4b1 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "express-static-gzip": "~2.1.7", "feed": "^4.2.2", "form-data": "~4.0.0", + "globalping": "^0.2.0", "gamedig": "^5.0.1", "html-escaper": "^3.0.3", "http-cookie-agent": "~5.0.4", diff --git a/server/model/monitor.js b/server/model/monitor.js index 2719b89a037..48f0196e2dd 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -33,7 +33,6 @@ const { checkStatusCode, getTotalClientInRoom, setting, - setSetting, httpNtlm, radius, kafkaProducerAsync, @@ -41,6 +40,8 @@ const { rootCertificatesFingerprints, axiosAbortSignal, checkCertificateHostname, + encodeBase64, + checkCertExpiryNotifications, } = require("../util-server"); const { R } = require("redbean-node"); const { BeanModel } = require("redbean-node/dist/bean-model"); @@ -141,11 +142,14 @@ class Monitor extends BeanModel { method: this.method, hostname: this.hostname, port: this.port, + location: this.location, + protocol: this.protocol, maxretries: this.maxretries, weight: this.weight, active: preloadData.activeStatus.get(this.id), forceInactive: preloadData.forceInactive.get(this.id), type: this.type, + subtype: this.subtype, timeout: this.timeout, interval: this.interval, retryInterval: this.retryInterval, @@ -289,17 +293,6 @@ class Monitor extends BeanModel { }; } - /** - * 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? @@ -421,6 +414,8 @@ class Monitor extends BeanModel { let previousBeat = null; let retries = 0; + this.rootCertificates = rootCertificates; + try { this.prometheus = new Prometheus(this, await this.getTags()); } catch (e) { @@ -482,7 +477,7 @@ class Monitor extends BeanModel { let basicAuthHeader = {}; if (this.auth_method === "basic") { basicAuthHeader = { - Authorization: "Basic " + this.encodeBase64(this.basic_auth_user, this.basic_auth_pass), + Authorization: "Basic " + encodeBase64(this.basic_auth_user, this.basic_auth_pass), }; } @@ -922,7 +917,7 @@ class Monitor extends BeanModel { ); } - if (!bean.ping) { + if (bean.ping === undefined || bean.ping === null) { bean.ping = dayjs().valueOf() - startTime; } } else if (this.type === "kafka-producer") { @@ -1572,64 +1567,6 @@ class Monitor extends BeanModel { return notificationList; } - /** - * checks certificate chain for expiring certificates - * @param {object} tlsInfoObject Information about certificate - * @returns {Promise} - */ - 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 @@ -2164,7 +2101,7 @@ class Monitor extends BeanModel { if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) { log.debug("monitor", `[${this.name}] call checkCertExpiryNotifications`); - await this.checkCertExpiryNotifications(tlsInfo); + await checkCertExpiryNotifications(this, tlsInfo); } } } diff --git a/server/monitor-types/globalping.js b/server/monitor-types/globalping.js new file mode 100644 index 00000000000..d6b98593815 --- /dev/null +++ b/server/monitor-types/globalping.js @@ -0,0 +1,482 @@ +const { MonitorType } = require("./monitor-type"); +const { Globalping, IpVersion } = require("globalping"); +const { Settings } = require("../settings"); +const { log, UP, DOWN, evaluateJsonQuery } = require("../../src/util"); +const { + checkStatusCode, + getOidcTokenClientCredentials, + encodeBase64, + getDaysRemaining, + checkCertExpiryNotifications, +} = require("../util-server"); +const { R } = require("redbean-node"); + +/** + * Globalping is a free and open-source tool that allows you to run network tests + * and measurements from thousands of community hosted probes around the world. + * + * Library documentation: https://github.com/jsdelivr/globalping-typescript + * + * API documentation: https://globalping.io/docs/api.globalping.io + */ +class GlobalpingMonitorType extends MonitorType { + name = "globalping"; + + httpUserAgent = ""; + + /** + * @inheritdoc + */ + constructor(httpUserAgent) { + super(); + this.httpUserAgent = httpUserAgent; + } + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + const apiKey = await Settings.get("globalpingApiToken"); + const client = new Globalping({ + auth: apiKey, + agent: this.httpUserAgent, + }); + + const hasAPIToken = !!apiKey; + switch (monitor.subtype) { + case "ping": + await this.ping(client, monitor, heartbeat, hasAPIToken); + break; + case "http": + await this.http(client, monitor, heartbeat, hasAPIToken); + break; + } + } + + /** + * Handles ping monitors. + * @param {Client} client - The client object. + * @param {Monitor} monitor - The monitor object. + * @param {Heartbeat} heartbeat - The heartbeat object. + * @param {boolean} hasAPIToken - Whether the monitor has an API token. + * @returns {Promise} A promise that resolves when the ping monitor is handled. + */ + async ping(client, monitor, heartbeat, hasAPIToken) { + const opts = { + type: "ping", + target: monitor.hostname, + inProgressUpdates: false, + limit: 1, + locations: [{ magic: monitor.location }], + measurementOptions: { + packets: monitor.ping_count, + protocol: monitor.protocol, + }, + }; + + if (monitor.protocol === "TCP" && monitor.port) { + opts.measurementOptions.port = monitor.port; + } + + if (monitor.ipFamily === "ipv4") { + opts.measurementOptions.ipVersion = IpVersion[4]; + } else if (monitor.ipFamily === "ipv6") { + opts.measurementOptions.ipVersion = IpVersion[6]; + } + + log.debug("monitor", `Globalping create measurement: ${JSON.stringify(opts)}`); + let res = await client.createMeasurement(opts); + + if (!res.ok) { + if (Globalping.isHttpStatus(429, res)) { + throw new Error(`Failed to create measurement: ${this.formatTooManyRequestsError(hasAPIToken)}`); + } + throw new Error(`Failed to create measurement: ${this.formatApiError(res.data.error)}`); + } + + log.debug("monitor", `Globalping fetch measurement: ${res.data.id}`); + let measurement = await client.awaitMeasurement(res.data.id); + + if (!measurement.ok) { + throw new Error( + `Failed to fetch measurement (${res.data.id}): ${this.formatApiError(measurement.data.error)}` + ); + } + + const probe = measurement.data.results[0].probe; + const result = measurement.data.results[0].result; + + if (result.status === "failed") { + heartbeat.msg = this.formatResponse(probe, `Failed: ${result.rawOutput}`); + heartbeat.status = DOWN; + return; + } + + if (!result.timings?.length) { + heartbeat.msg = this.formatResponse(probe, `Failed: ${result.rawOutput}`); + heartbeat.status = DOWN; + return; + } + + heartbeat.ping = result.stats.avg || 0; + heartbeat.msg = this.formatResponse(probe, "OK"); + heartbeat.status = UP; + } + + /** + * Handles HTTP monitors. + * @param {Client} client - The client object. + * @param {Monitor} monitor - The monitor object. + * @param {Heartbeat} heartbeat - The heartbeat object. + * @param {boolean} hasAPIToken - Whether the monitor has an API token. + * @returns {Promise} A promise that resolves when the HTTP monitor is handled. + */ + async http(client, monitor, heartbeat, hasAPIToken) { + const url = new URL(monitor.url); + + let protocol = url.protocol.replace(":", "").toUpperCase(); + if (monitor.protocol === "HTTP2") { + protocol = "HTTP2"; + } + + const basicAuthHeader = this.getBasicAuthHeader(monitor); + const oauth2AuthHeader = await this.getOauth2AuthHeader(monitor); + const headers = { + ...basicAuthHeader, + ...oauth2AuthHeader, + ...(monitor.headers ? JSON.parse(monitor.headers) : {}), + }; + + if (monitor.cacheBust) { + const randomFloatString = Math.random().toString(36); + const cacheBust = randomFloatString.substring(2); + url.searchParams.set("uptime_kuma_cachebuster", cacheBust); + } + + const opts = { + type: "http", + target: url.hostname, + inProgressUpdates: false, + limit: 1, + locations: [{ magic: monitor.location }], + measurementOptions: { + request: { + host: url.hostname, + path: url.pathname, + query: url.search ? url.search.slice(1) : undefined, + method: monitor.method, + headers, + }, + protocol: protocol, + }, + }; + + if (url.port) { + opts.measurementOptions.port = parseInt(url.port); + } + + if (monitor.ipFamily === "ipv4") { + opts.measurementOptions.ipVersion = IpVersion[4]; + } else if (monitor.ipFamily === "ipv6") { + opts.measurementOptions.ipVersion = IpVersion[6]; + } + + if (monitor.dns_resolve_server) { + opts.measurementOptions.resolver = monitor.dns_resolve_server; + } + + log.debug("monitor", `Globalping create measurement: ${JSON.stringify(opts)}`); + let res = await client.createMeasurement(opts); + + if (!res.ok) { + if (Globalping.isHttpStatus(429, res)) { + throw new Error(`Failed to create measurement: ${this.formatTooManyRequestsError(hasAPIToken)}`); + } + throw new Error(`Failed to create measurement: ${this.formatApiError(res.data.error)}`); + } + + log.debug("monitor", `Globalping fetch measurement: ${res.data.id}`); + let measurement = await client.awaitMeasurement(res.data.id); + + if (!measurement.ok) { + throw new Error( + `Failed to fetch measurement (${res.data.id}): ${this.formatApiError(measurement.data.error)}` + ); + } + + const probe = measurement.data.results[0].probe; + const result = measurement.data.results[0].result; + + if (result.status === "failed") { + heartbeat.msg = this.formatResponse(probe, `Failed: ${result.rawOutput}`); + heartbeat.status = DOWN; + return; + } + + heartbeat.ping = result.timings.total || 0; + + if (!checkStatusCode(result.statusCode, JSON.parse(monitor.accepted_statuscodes_json))) { + heartbeat.msg = this.formatResponse( + probe, + `Status code ${result.statusCode} not accepted. Output: ${result.rawOutput}` + ); + heartbeat.status = DOWN; + return; + } + + heartbeat.msg = this.formatResponse(probe, `${result.statusCode} - ${result.statusCodeName}`); + + // keyword + if (monitor.keyword) { + await this.handleKeywordForHTTP(monitor, heartbeat, result, probe); + return; + } + + // json-query + if (monitor.expectedValue) { + await this.handleJSONQueryForHTTP(monitor, heartbeat, result, probe); + return; + } + + await this.handleTLSInfo(monitor, protocol, probe, result.tls); + + heartbeat.msg = this.formatResponse(probe, "OK"); + heartbeat.status = UP; + } + + /** + * Handles keyword for HTTP monitors. + * @param {Monitor} monitor - The monitor object. + * @param {Heartbeat} heartbeat - The heartbeat object. + * @param {Result} result - The result object. + * @param {Probe} probe - The probe object. + * @returns {Promise} A promise that resolves when the keyword is handled. + */ + async handleKeywordForHTTP(monitor, heartbeat, result, probe) { + let data = result.rawOutput; + let keywordFound = data.includes(monitor.keyword); + + if (keywordFound === Boolean(monitor.invertKeyword)) { + data = data.replace(/<[^>]*>?|[\n\r]|\s+/gm, " ").trim(); + if (data.length > 50) { + data = data.substring(0, 47) + "..."; + } + throw new Error( + heartbeat.msg + ", but keyword is " + (keywordFound ? "present" : "not") + " in [" + data + "]" + ); + } + + heartbeat.msg += ", keyword " + (keywordFound ? "is" : "not") + " found"; + heartbeat.status = UP; + } + + /** + * Handles JSON query for HTTP monitors. + * @param {Monitor} monitor - The monitor object. + * @param {Heartbeat} heartbeat - The heartbeat object. + * @param {Result} result - The result object. + * @param {Probe} probe - The probe object. + * @returns {Promise} A promise that resolves when the JSON query is handled. + */ + async handleJSONQueryForHTTP(monitor, heartbeat, result, probe) { + const { status, response } = await evaluateJsonQuery( + result.rawOutput, + monitor.jsonPath, + monitor.jsonPathOperator, + monitor.expectedValue + ); + + if (!status) { + throw new Error( + this.formatResponse( + probe, + `JSON query does not pass (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})` + ) + ); + } + + heartbeat.msg = this.formatResponse( + probe, + `JSON query passes (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})` + ); + heartbeat.status = UP; + } + + /** + * Updates the TLS information for a monitor. + * @param {object} monitor - The monitor object. + * @param {string} protocol - The protocol used for the monitor. + * @param {object} probe - The probe object containing location information. + * @param {object} tlsInfo - The TLS information object. + * @returns {Promise} + */ + async handleTLSInfo(monitor, protocol, probe, tlsInfo) { + if (!tlsInfo) { + return; + } + + if (!monitor.ignoreTls && protocol === "HTTPS" && !tlsInfo.authorized) { + throw new Error(this.formatResponse(probe, `TLS certificate is not authorized: ${tlsInfo.error}`)); + } + + let tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [monitor.id]); + + if (tlsInfoBean == null) { + tlsInfoBean = R.dispense("monitor_tls_info"); + tlsInfoBean.monitor_id = monitor.id; + } else { + try { + let oldCertInfo = JSON.parse(tlsInfoBean.info_json); + + if ( + oldCertInfo && + oldCertInfo.certInfo && + oldCertInfo.certInfo.fingerprint256 !== tlsInfo.fingerprint256 + ) { + log.debug("monitor", "Resetting sent_history"); + await R.exec( + "DELETE FROM notification_sent_history WHERE type = 'certificate' AND monitor_id = ?", + [monitor.id] + ); + } + } catch (e) {} + } + + const validTo = new Date(tlsInfo.expiresAt); + const certResult = { + valid: tlsInfo.authorized, + certInfo: { + subject: tlsInfo.subject, + issuer: tlsInfo.issuer, + validTo: validTo, + daysRemaining: getDaysRemaining(new Date(), validTo), + fingerprint: tlsInfo.fingerprint256, + fingerprint256: tlsInfo.fingerprint256, + certType: "", + }, + }; + + tlsInfoBean.info_json = JSON.stringify(certResult); + await R.store(tlsInfoBean); + + if (monitor.prometheus) { + monitor.prometheus.update(null, certResult); + } + + if (!monitor.ignoreTls && monitor.expiryNotification) { + await checkCertExpiryNotifications(monitor, certResult); + } + } + + /** + * Generates the OAuth2 authorization header for the monitor if it is enabled. + * @param {object} monitor - The monitor object containing authentication information. + * @returns {Promise} The OAuth2 authorization header. + */ + async getOauth2AuthHeader(monitor) { + if (monitor.auth_method !== "oauth2-cc") { + return {}; + } + + try { + if (new Date((monitor.oauthAccessToken?.expires_at || 0) * 1000) <= new Date()) { + const oAuthAccessToken = await getOidcTokenClientCredentials( + monitor.oauth_token_url, + monitor.oauth_client_id, + monitor.oauth_client_secret, + monitor.oauth_scopes, + monitor.oauth_audience, + monitor.oauth_auth_method + ); + log.debug( + "monitor", + `[${monitor.name}] Obtained oauth access-token. Expires at ${new Date(oAuthAccessToken.expires_at * 1000)}` + ); + + monitor.oauthAccessToken = oAuthAccessToken; + } + return { + Authorization: monitor.oauthAccessToken.token_type + " " + monitor.oauthAccessToken.access_token, + }; + } catch (e) { + throw new Error("The oauth config is invalid. " + e.message); + } + } + + /** + * Generates the basic authentication header for a monitor if it is enabled. + * @param {object} monitor - The monitor object. + * @returns {object} The basic authentication header. + */ + getBasicAuthHeader(monitor) { + if (monitor.auth_method !== "basic") { + return {}; + } + + return { + Authorization: "Basic " + encodeBase64(monitor.basic_auth_user, monitor.basic_auth_pass), + }; + } + + /** + * Generates a formatted error message for API errors. + * @param {Error} error - The API error object. + * @returns {string} The formatted error message. + */ + formatApiError(error) { + let str = `${error.type} ${error.message}.`; + if (error.params) { + for (const key in error.params) { + str += `\n${key}: ${error.params[key]}`; + } + } + return str; + } + + /** + * Generates a formatted error message for too many requests. + * @param {boolean} hasAPIToken - Indicates whether an API token is available. + * @returns {string} The formatted error message. + */ + formatTooManyRequestsError(hasAPIToken) { + const creditsHelpLink = "https://dash.globalping.io?view=add-credits"; + if (hasAPIToken) { + return `You have run out of credits. Get higher limits by sponsoring us or hosting probes. Learn more at ${creditsHelpLink}.`; + } + return `You have run out of credits. Get higher limits by creating an account. Sign up at ${creditsHelpLink}.`; + } + + /** + * Returns the formatted probe location string. e.g "Ashburn (VA), US, NA, Amazon.com (AS14618), (aws-us-east-1)" + * @param {object} probe - The probe object containing location information. + * @returns {string} The formatted probe location string. + */ + formatProbeLocation(probe) { + let tag = ""; + + for (const t of probe.tags) { + // If tag ends in a number, it's likely a region code and should be displayed + if (Number.isInteger(Number(t.slice(-1)))) { + tag = t; + break; + } + } + return `${probe.city}${probe.state ? ` (${probe.state})` : ""}, ${probe.country}, ${probe.continent}, ${ + probe.network + } (AS${probe.asn})${tag ? `, (${tag})` : ""}`; + } + + /** + * Formats the response text with the probe location. + * @param {object} probe - The probe object containing location information. + * @param {string} text - The response text to append. + * @returns {string} The formatted response text. + */ + formatResponse(probe, text) { + return `${this.formatProbeLocation(probe)} : ${text}`; + } +} + +module.exports = { + GlobalpingMonitorType, +}; diff --git a/server/notification-providers/notification-provider.js b/server/notification-providers/notification-provider.js index 5360007d1bf..2a91ca1e401 100644 --- a/server/notification-providers/notification-provider.js +++ b/server/notification-providers/notification-provider.js @@ -45,6 +45,15 @@ class NotificationProvider { return monitorJSON["hostname"] + ":" + monitorJSON["port"]; } return monitorJSON["hostname"]; + case "globalping": + switch (monitorJSON["subtype"]) { + case "ping": + return monitorJSON["hostname"]; + case "http": + return monitorJSON["url"]; + default: + return ""; + } default: if (!["https://", "http://", ""].includes(monitorJSON["url"])) { return monitorJSON["url"]; diff --git a/server/server.js b/server/server.js index 885e8834030..b919c62710f 100644 --- a/server/server.js +++ b/server/server.js @@ -745,7 +745,7 @@ let needSetup = false; * List of frontend-only properties that should not be saved to the database. * Should clean up before saving to the database. */ - const frontendOnlyProperties = ["humanReadableInterval"]; + const frontendOnlyProperties = ["humanReadableInterval", "responsecheck"]; for (const prop of frontendOnlyProperties) { if (prop in monitor) { delete monitor[prop]; @@ -823,6 +823,7 @@ let needSetup = false; bean.description = monitor.description; bean.parent = monitor.parent; bean.type = monitor.type; + bean.subtype = monitor.subtype; bean.url = monitor.url; bean.wsIgnoreSecWebsocketAcceptHeader = monitor.wsIgnoreSecWebsocketAcceptHeader; bean.wsSubprotocol = monitor.wsSubprotocol; @@ -849,6 +850,8 @@ let needSetup = false; bean.game = monitor.game; bean.maxretries = monitor.maxretries; bean.port = parseInt(monitor.port); + bean.location = monitor.location; + bean.protocol = monitor.protocol; if (isNaN(bean.port)) { bean.port = null; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 030ee078743..6709065b9ad 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -126,6 +126,7 @@ class UptimeKumaServer { UptimeKumaServer.monitorTypeList["gamedig"] = new GameDigMonitorType(); UptimeKumaServer.monitorTypeList["port"] = new TCPMonitorType(); UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType(); + UptimeKumaServer.monitorTypeList["globalping"] = new GlobalpingMonitorType(this.getUserAgent()); UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType(); UptimeKumaServer.monitorTypeList["system-service"] = new SystemServiceMonitorType(); UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType(); @@ -576,6 +577,7 @@ const { SIPMonitorType } = require("./monitor-types/sip-options"); const { GameDigMonitorType } = require("./monitor-types/gamedig"); const { TCPMonitorType } = require("./monitor-types/tcp.js"); const { ManualMonitorType } = require("./monitor-types/manual"); +const { GlobalpingMonitorType } = require("./monitor-types/globalping"); const { RedisMonitorType } = require("./monitor-types/redis"); const { SystemServiceMonitorType } = require("./monitor-types/system-service"); const { MssqlMonitorType } = require("./monitor-types/mssql"); diff --git a/server/util-server.js b/server/util-server.js index a22d4d09e8c..773e464e7b0 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -414,6 +414,32 @@ exports.setSettings = async function (type, data) { await Settings.setSettings(type, data); }; +// ssl-checker by @dyaa +//https://github.com/dyaa/ssl-checker/blob/master/src/index.ts + +/** + * Get number of days between two dates + * @param {Date} validFrom Start date + * @param {Date} validTo End date + * @returns {number} Number of days + */ +const getDaysBetween = (validFrom, validTo) => Math.round(Math.abs(+validFrom - +validTo) / 8.64e7); + +/** + * Get days remaining from a time range + * @param {Date} validFrom Start date + * @param {Date} validTo End date + * @returns {number} Number of days remaining + */ +const getDaysRemaining = (validFrom, validTo) => { + const daysRemaining = getDaysBetween(validFrom, validTo); + if (new Date(validTo).getTime() < new Date().getTime()) { + return -daysRemaining; + } + return daysRemaining; +}; +module.exports.getDaysRemaining = getDaysRemaining; + /** * Fix certificate info for display * @param {object} info The chain obtained from getPeerCertificate() @@ -869,6 +895,81 @@ function fsExists(path) { } module.exports.fsExists = fsExists; +/** + * Encode user and password to Base64 encoding + * for HTTP "basic" auth, as per RFC-7617 + * @param {string|null} user - The username (defaults to empty string if null/undefined) + * @param {string|null} pass - The password (defaults to empty string if null/undefined) + * @returns {string} Encoded Base64 string + */ +function encodeBase64(user, pass) { + return Buffer.from(`${user || ""}:${pass || ""}`).toString("base64"); +} +module.exports.encodeBase64 = encodeBase64; + +/** + * checks certificate chain for expiring certificates + * @param {object} monitor - The monitor object + * @param {object} tlsInfoObject Information about certificate + * @returns {Promise} + */ +async function checkCertExpiryNotifications(monitor, tlsInfoObject) { + if (!tlsInfoObject || !tlsInfoObject.certInfo || !tlsInfoObject.certInfo.daysRemaining) { + return; + } + + let notificationList = await R.getAll( + "SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id ", + [monitor.id] + ); + + 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 Settings.get("tlsExpiryNotifyDays"); + if (notifyDays == null || !Array.isArray(notifyDays)) { + // Reset Default + await Settings.setSetting("tlsExpiryNotifyDays", [7, 14, 21], "general"); + notifyDays = [7, 14, 21]; + } + + for (const targetDays of notifyDays) { + let certInfo = tlsInfoObject.certInfo; + while (certInfo) { + let subjectCN = certInfo.subject["CN"]; + if (monitor.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 monitor.sendCertNotificationByTargetDays( + subjectCN, + certInfo.certType, + certInfo.daysRemaining, + targetDays, + notificationList + ); + } + certInfo = certInfo.issuerCertificate; + } + } +} +module.exports.checkCertExpiryNotifications = checkCertExpiryNotifications; + /** * By default, command-exists will throw a null error if the command does not exist, which is ugly. The function makes it better. * Read more: https://github.com/mathisonian/command-exists/issues/22 diff --git a/src/components/settings/General.vue b/src/components/settings/General.vue index 4b29dd127f5..5e93d8ad4ea 100644 --- a/src/components/settings/General.vue +++ b/src/components/settings/General.vue @@ -135,6 +135,21 @@ + +
+ + + + https://dash.globalping.io + +
+
+ + +
@@ -1260,7 +1400,9 @@ monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || - (monitor.type === 'port' && ['starttls', 'secure'].includes(monitor.smtpSecurity)) + (monitor.type === 'port' && + ['starttls', 'secure'].includes(monitor.smtpSecurity)) || + (monitor.type === 'globalping' && monitor.subtype === 'http') " class="my-3 form-check" :title="monitor.ignoreTls ? $t('ignoredTLSError') : ''" @@ -1348,7 +1490,8 @@ monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || - monitor.type === 'redis' + monitor.type === 'redis' || + (monitor.type === 'globalping' && monitor.subtype === 'http') " class="my-3 form-check" > @@ -1368,7 +1511,8 @@ v-if=" monitor.type === 'http' || monitor.type === 'keyword' || - monitor.type === 'json-query' + monitor.type === 'json-query' || + (monitor.type === 'globalping' && monitor.subtype === 'http') " class="my-3 form-check" > @@ -1427,7 +1571,13 @@
-
+
+ +
+ + + + +
+ {{ $t("acceptedStatusCodesDescription") }} +
+
+
@@ -2156,6 +2331,281 @@ + + +