feat: add Globalping support (#6163)

Co-authored-by: Frank Elsinga <frank@elsinga.de>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Radu Lucuț 2026-01-26 07:39:42 +02:00 committed by GitHub
parent 66c8bac506
commit bad679ee47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 2070 additions and 80 deletions

View File

@ -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");
});
};

21
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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<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
@ -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);
}
}
}

View File

@ -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<void>} 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<void>} 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<void>} 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<void>} 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<void>}
*/
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<object>} 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,
};

View File

@ -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"];

View File

@ -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;

View File

@ -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");

View File

@ -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<void>}
*/
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

View File

@ -135,6 +135,21 @@
</i18n-t>
</div>
<!-- Globalping API Token -->
<div class="mb-4">
<label class="form-label" for="globalpingApiToken">
{{ $t("Globalping API Token") }}
</label>
<HiddenInput
id="globalpingApiToken"
v-model="settings.globalpingApiToken"
autocomplete="new-password"
/>
<i18n-t keypath="globalpingApiTokenDescription" tag="div" class="form-text">
<a href="https://dash.globalping.io" target="_blank">https://dash.globalping.io</a>
</i18n-t>
</div>
<!-- DNS Cache (nscd) -->
<div v-if="$root.info.isContainer" class="mb-4">
<label class="form-label">

View File

@ -1319,6 +1319,21 @@
"Send UP silently": "Send UP silently",
"Send DOWN silently": "Send DOWN silently",
"Installing a Nextcloud Talk bot requires administrative access to the server.": "Installing a Nextcloud Talk bot requires administrative access to the server.",
"Globalping - Access global monitoring probes": "Globalping - Access global monitoring probes",
"GlobalpingDescription": "Globalping provides access to thousands of community hosted probes to run network tests and measurements. A limit of 250 tests per hour is set for all anonymous users. To double the limit to 500 per hour please save your token in {accountSettings}.",
"Globalping API Token": "Globalping API Token",
"globalpingApiTokenDescription": "Get your Globalping API Token at {0}.",
"GlobalpingHostname": "A publicly reachable measurement target. Typically a hostname or IPv4/IPv6 address, depending on the measurement type.",
"GlobalpingLocation": "The location field accepts continents, countries, regions, cities, ASNs, ISPs, or cloud regions. You can combine filters with {plus} (e.g {amazonPlusGermany} or {comcastPlusCalifornia}). If latency is an important metric, use filters to narrow down the location to a small region to avoid spikes. {fullDocs}.",
"GlobalpingLocationDocs": "Full location input documentation",
"GlobalpingIpFamilyInfo": "The IP version to use. Only allowed if the target is a hostname.",
"GlobalpingResolverInfo": "IPv4/IPv6 address or a fully Qualified Domain Name (FQDN). Defaults to the probe's local network resolver. You can change the resolver server anytime.",
"Resolver Server": "Resolver Server",
"Protocol": "Protocol",
"account settings": "account settings",
"Location": "Location",
"Monitor Subtype": "Monitor Subtype",
"Check for": "Check for",
"Number of retry attempts if webhook fails": "Number of retry attempts (every 60-180 seconds) if the webhook fails.",
"Maximum Retries": "Maximum Retries",
"Template ID": "Template ID",

View File

@ -35,6 +35,16 @@
</a>
<span v-if="monitor.type === 'port'">TCP Port {{ monitor.hostname }}:{{ monitor.port }}</span>
<span v-if="monitor.type === 'ping'">Ping: {{ monitor.hostname }}</span>
<span v-if="monitor.type === 'globalping'">
<a v-if="monitor.subtype === 'http'" :href="monitor.url" target="_blank" rel="noopener noreferrer">
{{ filterPassword(monitor.url) }}
</a>
<span v-if="monitor.hostname">{{ monitor.hostname }}</span>
<br />
<span>{{ $t("Location") }}:</span>
<span class="keyword">{{ monitor.location }}</span>
<br />
</span>
<span v-if="monitor.type === 'keyword'">
<br />
<span>{{ $t("Keyword") }}:</span>

View File

@ -8,6 +8,17 @@
<div class="col-md-6">
<h2 class="mb-2">{{ $t("General") }}</h2>
<i18n-t
v-if="monitor.type === 'globalping'"
keypath="GlobalpingDescription"
tag="p"
class="form-text"
>
<template #accountSettings>
<router-link to="/settings/general">{{ $t("account settings") }}</router-link>
</template>
</i18n-t>
<div class="my-3">
<label for="type" class="form-label">{{ $t("Monitor Type") }}</label>
<select
@ -55,6 +66,9 @@
</optgroup>
<optgroup :label="$t('Specific Monitor Type')">
<option value="globalping">
{{ $t("Globalping - Access global monitoring probes") }}
</option>
<option value="steam">
{{ $t("Steam Game Server") }}
</option>
@ -98,6 +112,19 @@
{{ $t("tailscalePingWarning") }}
</div>
<div v-if="monitor.type === 'globalping'" class="my-3">
<label for="subtype" class="form-label">{{ $t("Monitor Subtype") }}</label>
<select
id="subtype"
v-model="monitor.subtype"
class="form-select"
data-testid="monitor-subtype-select"
>
<option value="ping">Ping</option>
<option value="http">HTTP(s)</option>
</select>
</div>
<div v-if="monitor.type === 'sip-options'" class="alert alert-warning" role="alert">
{{ $t("sipsakPingWarning") }}
</div>
@ -442,6 +469,116 @@
</div>
</div>
<!-- Globalping -->
<template v-if="monitor.type === 'globalping'">
<!-- Hostname -->
<div v-if="monitor.subtype === 'ping'" class="my-3">
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
<input
id="hostname"
v-model="monitor.hostname"
type="text"
class="form-control"
:pattern="ipOrHostnameRegexPattern"
required
data-testid="hostname-input"
/>
<div class="form-text">
{{ $t("GlobalpingHostname") }}
</div>
</div>
<div v-if="monitor.subtype === 'http'" class="my-3">
<label for="url" class="form-label">{{ $t("URL") }}</label>
<input
id="url"
v-model="monitor.url"
type="url"
class="form-control"
pattern="https?://.+"
required
data-testid="url-input"
/>
<div class="form-text">
{{ $t("GlobalpingHostname") }}
</div>
</div>
<!-- Location -->
<div class="my-3">
<label for="location" class="form-label">{{ $t("Location") }}</label>
<input
id="location"
v-model="monitor.location"
type="text"
class="form-control"
required
/>
<i18n-t keypath="GlobalpingLocation" tag="div" class="form-text">
<template #plus>
<code>+</code>
</template>
<template #amazonPlusGermany>
<code>amazon+germany</code>
</template>
<template #comcastPlusCalifornia>
<code>comcast+california</code>
</template>
<template #fullDocs>
<a
href="https://github.com/jsdelivr/globalping?tab=readme-ov-file#basic-location-targeting-"
target="_blank"
>
{{ $t("GlobalpingLocationDocs") }}
</a>
</template>
</i18n-t>
</div>
<!-- IP Family -->
<div class="my-3">
<label for="ipFamily" class="form-label">{{ $t("Ip Family") }}</label>
<select id="ipFamily" v-model="monitor.ipFamily" class="form-select">
<option :value="null">{{ $t("auto-select") }}</option>
<option value="ipv4">IPv4</option>
<option value="ipv6">IPv6</option>
</select>
<div class="form-text">
{{ $t("GlobalpingIpFamilyInfo") }}
</div>
</div>
<div v-if="monitor.subtype === 'http'" class="my-3">
<label for="dns_resolve_server" class="form-label">
{{ $t("Resolver Server") }}
</label>
<input
id="dns_resolve_server"
v-model="monitor.dns_resolve_server"
type="text"
class="form-control"
/>
<div class="form-text">
{{ $t("GlobalpingResolverInfo") }}
</div>
</div>
<!-- Protocol -->
<div class="my-3">
<label for="protocol" class="form-label">{{ $t("Protocol") }}</label>
<select id="protocol" v-model="monitor.protocol" class="form-select" required>
<template v-if="monitor.subtype === 'ping'">
<option value="ICMP">ICMP</option>
<option value="TCP">TCP</option>
</template>
<template v-else-if="monitor.subtype === 'http'">
<option :value="null">{{ $t("auto-select") }}</option>
<option value="HTTP2">HTTP2</option>
</template>
</select>
</div>
</template>
<!-- Port -->
<!-- For TCP Port / Steam / MQTT / Radius Type / SNMP / SIP Options -->
<div
@ -453,7 +590,10 @@
monitor.type === 'radius' ||
monitor.type === 'smtp' ||
monitor.type === 'snmp' ||
monitor.type === 'sip-options'
monitor.type === 'sip-options' ||
(monitor.type === 'globalping' &&
monitor.subtype === 'ping' &&
monitor.protocol === 'TCP')
"
class="my-3"
>
@ -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 @@
</div>
<!-- Max Packets / Count -->
<div v-if="monitor.type === 'ping'" class="my-3">
<div
v-if="
monitor.type === 'ping' ||
(monitor.type === 'globalping' && monitor.subtype === 'ping')
"
class="my-3"
>
<label for="ping-count" class="form-label">{{ $t("pingCountLabel") }}</label>
<input
id="ping-count"
@ -1688,6 +1838,31 @@
</div>
</template>
<!-- Globalping Accepted Status Codes -->
<div v-if="monitor.type === 'globalping' && monitor.subtype === 'http'" class="my-3">
<label for="acceptedStatusCodes" class="form-label">
{{ $t("Accepted Status Codes") }}
</label>
<VueMultiselect
id="acceptedStatusCodes"
v-model="monitor.accepted_statuscodes"
:options="acceptedStatusCodeOptions"
:multiple="true"
:close-on-select="false"
:clear-on-select="false"
:preserve-search="true"
:placeholder="$t('Pick Accepted Status Codes...')"
:preselect-first="false"
:max-height="600"
:taggable="true"
></VueMultiselect>
<div class="form-text">
{{ $t("acceptedStatusCodesDescription") }}
</div>
</div>
<!-- Parent Monitor -->
<div class="my-3">
<label for="monitorGroupSelector" class="form-label">{{ $t("Monitor Group") }}</label>
@ -2156,6 +2331,281 @@
</template>
</template>
<!-- Globalping HTTP Options -->
<template v-if="monitor.type === 'globalping' && monitor.subtype === 'http'">
<h2 class="mt-5 mb-2">{{ $t("HTTP Options") }}</h2>
<!-- Method -->
<div class="my-3">
<label for="method" class="form-label">{{ $t("Method") }}</label>
<select id="method" v-model="monitor.method" class="form-select">
<option value="HEAD">HEAD</option>
<option value="GET">GET</option>
<option value="OPTIONS">OPTIONS</option>
</select>
</div>
<!-- Headers -->
<div class="my-3">
<label for="headers" class="form-label">{{ $t("Headers") }}</label>
<textarea
id="headers"
v-model="monitor.headers"
class="form-control"
:placeholder="headersPlaceholder"
></textarea>
</div>
<!-- HTTP Auth -->
<h4 class="mt-5 mb-2">{{ $t("Authentication") }}</h4>
<!-- Method -->
<div class="my-3">
<label for="method" class="form-label">{{ $t("Method") }}</label>
<select id="method" v-model="monitor.authMethod" class="form-select">
<option :value="null">
{{ $t("None") }}
</option>
<option value="basic">
{{ $t("HTTP Basic Auth") }}
</option>
<option value="oauth2-cc">
{{ $t("OAuth2: Client Credentials") }}
</option>
</select>
</div>
<template v-if="monitor.authMethod === 'basic'">
<div class="my-3">
<label for="basicauth-user" class="form-label">{{ $t("Username") }}</label>
<input
id="basicauth-user"
v-model="monitor.basic_auth_user"
type="text"
class="form-control"
:placeholder="$t('Username')"
/>
</div>
<div class="my-3">
<label for="basicauth-pass" class="form-label">{{ $t("Password") }}</label>
<input
id="basicauth-pass"
v-model="monitor.basic_auth_pass"
type="password"
autocomplete="new-password"
class="form-control"
:placeholder="$t('Password')"
/>
</div>
</template>
<template v-else-if="monitor.authMethod === 'oauth2-cc'">
<div class="my-3">
<label for="oauth_auth_method" class="form-label">
{{ $t("Authentication Method") }}
</label>
<select
id="oauth_auth_method"
v-model="monitor.oauth_auth_method"
class="form-select"
>
<option value="client_secret_basic">
{{ $t("Authorization Header") }}
</option>
<option value="client_secret_post">
{{ $t("Form Data Body") }}
</option>
</select>
</div>
<div class="my-3">
<label for="oauth_token_url" class="form-label">
{{ $t("OAuth Token URL") }}
</label>
<input
id="oauth_token_url"
v-model="monitor.oauth_token_url"
type="text"
class="form-control"
:placeholder="$t('OAuth Token URL')"
required
/>
</div>
<div class="my-3">
<label for="oauth_client_id" class="form-label">{{ $t("Client ID") }}</label>
<input
id="oauth_client_id"
v-model="monitor.oauth_client_id"
type="text"
class="form-control"
:placeholder="$t('Client ID')"
required
/>
</div>
<template
v-if="
monitor.oauth_auth_method === 'client_secret_post' ||
monitor.oauth_auth_method === 'client_secret_basic'
"
>
<div class="my-3">
<label for="oauth_client_secret" class="form-label">
{{ $t("Client Secret") }}
</label>
<input
id="oauth_client_secret"
v-model="monitor.oauth_client_secret"
type="password"
class="form-control"
:placeholder="$t('Client Secret')"
required
/>
</div>
<div class="my-3">
<label for="oauth_scopes" class="form-label">{{ $t("OAuth Scope") }}</label>
<input
id="oauth_scopes"
v-model="monitor.oauth_scopes"
type="text"
class="form-control"
:placeholder="$t('Optional: Space separated list of scopes')"
/>
</div>
<div class="my-3">
<label for="oauth_audience" class="form-label">
{{ $t("OAuth Audience") }}
</label>
<input
id="oauth_audience"
v-model="monitor.oauth_audience"
type="text"
class="form-control"
:placeholder="$t('Optional: The audience to request the JWT for')"
/>
</div>
</template>
</template>
<!-- Response -->
<h2 class="mt-5 mb-2">{{ $t("Response") }}</h2>
<div class="my-3">
<label for="checkfor" class="form-label">{{ $t("Check for") }}</label>
<select id="checkfor" v-model="monitor.responsecheck" class="form-select">
<option :value="null">
{{ $t("None") }}
</option>
<option value="keyword">
{{ $t("Keyword") }}
</option>
<option value="json-query">
{{ $t("Json Query Expression") }}
</option>
</select>
</div>
<!-- Keyword -->
<template v-if="monitor.responsecheck === 'keyword'">
<div class="my-3">
<label for="keyword" class="form-label">{{ $t("Keyword") }}</label>
<input
id="keyword"
v-model="monitor.keyword"
type="text"
class="form-control"
/>
<div class="form-text">
{{ $t("keywordDescription") }}
</div>
</div>
<!-- Invert keyword -->
<div class="my-3 form-check">
<input
id="invert-keyword"
v-model="monitor.invertKeyword"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label" for="invert-keyword">
{{ $t("Invert Keyword") }}
</label>
<div class="form-text">
{{ $t("invertKeywordDescription") }}
</div>
</div>
</template>
<!-- Json Query -->
<template v-if="monitor.responsecheck === 'json-query'">
<div class="my-3">
<div class="my-2">
<label for="jsonPath" class="form-label mb-0">
{{ $t("Json Query Expression") }}
</label>
<i18n-t tag="div" class="form-text mb-2" keypath="jsonQueryDescription">
<a href="https://jsonata.org/">jsonata.org</a>
<a href="https://try.jsonata.org/">{{ $t("playground") }}</a>
</i18n-t>
<input
id="jsonPath"
v-model="monitor.jsonPath"
type="text"
class="form-control"
placeholder="$"
required
/>
</div>
<div class="d-flex align-items-start">
<div class="me-2">
<label for="json_path_operator" class="form-label">
{{ $t("Condition") }}
</label>
<select
id="json_path_operator"
v-model="monitor.jsonPathOperator"
class="form-select me-3"
required
>
<option value=">">&gt;</option>
<option value=">=">&gt;=</option>
<option value="<">&lt;</option>
<option value="<=">&lt;=</option>
<option value="!=">&#33;=</option>
<option value="==">==</option>
<option value="contains">contains</option>
</select>
</div>
<div class="flex-grow-1">
<label for="expectedValue" class="form-label">
{{ $t("Expected Value") }}
</label>
<input
v-if="
monitor.jsonPathOperator !== 'contains' &&
monitor.jsonPathOperator !== '==' &&
monitor.jsonPathOperator !== '!='
"
id="expectedValue"
v-model="monitor.expectedValue"
type="number"
class="form-control"
required
step=".01"
/>
<input
v-else
id="expectedValue"
v-model="monitor.expectedValue"
type="text"
class="form-control"
required
/>
</div>
</div>
</div>
</template>
</template>
<!-- gRPC Options -->
<template v-if="monitor.type === 'grpc-keyword'">
<!-- Proto service enable TLS -->
@ -2298,6 +2748,8 @@ const monitorDefaults = {
url: "https://",
wsSubprotocol: "",
method: "GET",
protocol: null,
location: "world",
ipFamily: null,
interval: 60,
humanReadableInterval: timeDurationFormatter.secondsToHumanReadableFormat(60),
@ -2316,7 +2768,7 @@ const monitorDefaults = {
saveErrorResponse: true,
responseMaxLength: 1024,
dns_resolve_type: "A",
dns_resolve_server: "1.1.1.1",
dns_resolve_server: "",
docker_container: "",
docker_host: null,
proxyId: null,
@ -2754,6 +3206,14 @@ message HealthCheckResponse {
},
"monitor.type"(newType, oldType) {
if (newType === "globalping" && !this.monitor.subtype) {
this.monitor.subtype = "ping";
}
if (newType === "dns" && !this.monitor.dns_resolve_server) {
this.monitor.dns_resolve_server = "1.1.1.1";
}
if (oldType && this.monitor.type === "websocket-upgrade") {
this.monitor.url = "wss://";
this.monitor.accepted_statuscodes = ["1000"];
@ -2774,6 +3234,8 @@ message HealthCheckResponse {
this.monitor.port = "1812";
} else if (this.monitor.type === "snmp") {
this.monitor.port = "161";
} else if (this.monitor.type === "globalping" && this.monitor.subtype === "ping") {
this.monitor.port = "80";
} else {
this.monitor.port = undefined;
}
@ -2840,6 +3302,43 @@ message HealthCheckResponse {
}
},
"monitor.subtype"(newSubtype, oldSubtype) {
if (!oldSubtype && !this.monitor.protocol) {
if (newSubtype === "ping") {
this.monitor.protocol = "ICMP";
} else if (newSubtype === "http") {
this.monitor.protocol = null;
}
}
if (newSubtype !== oldSubtype) {
if (newSubtype === "ping") {
this.monitor.protocol = "ICMP";
this.monitor.port = "80";
} else if (newSubtype === "http") {
this.monitor.protocol = null;
}
}
if (newSubtype === "http") {
if (this.monitor.keyword) {
this.monitor.responsecheck = "keyword";
} else if (this.monitor.expectedValue) {
this.monitor.responsecheck = "json-query";
} else {
this.monitor.responsecheck = null;
}
}
},
"monitor.responsecheck"(newSubtype) {
if (newSubtype !== "keyword") {
this.monitor.keyword = null;
}
if (newSubtype !== "json-query") {
this.monitor.expectedValue = null;
}
},
currentGameObject(newGameObject, previousGameObject) {
if (!this.monitor.port || (previousGameObject && previousGameObject.options.port === this.monitor.port)) {
this.monitor.port = newGameObject.options.port;
@ -2945,6 +3444,16 @@ message HealthCheckResponse {
this.monitor.tags = undefined;
}
if (this.monitor.type === "globalping" && this.monitor.subtype === "http") {
if (this.monitor.keyword) {
this.monitor.responsecheck = "keyword";
} else if (this.monitor.expectedValue) {
this.monitor.responsecheck = "json-query";
} else {
this.monitor.responsecheck = null;
}
}
// Handling for monitors that are created before 1.7.0
if (this.monitor.retryInterval === 0) {
this.monitor.retryInterval = this.monitor.interval;

View File

@ -0,0 +1,868 @@
const { describe, test, mock } = require("node:test");
const assert = require("node:assert");
const { encodeBase64 } = require("../../server/util-server");
const { UP, DOWN, PENDING } = require("../../src/util");
describe("GlobalpingMonitorType", () => {
const { GlobalpingMonitorType } = require("../../server/monitor-types/globalping");
describe("ping", () => {
test("should handle successful ping", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
id: "2g8T7V3OwXG3JV6Y10011zF2v",
});
const measurement = createPingMeasurement();
const awaitResponse = createMockResponse(measurement);
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse);
const monitor = {
hostname: "example.com",
location: "North America",
ping_count: 3,
protocol: "ICMP",
ipFamily: "ipv4",
};
const heartbeat = {
status: PENDING,
msg: "",
ping: 0,
};
await monitorType.ping(mockClient, monitor, heartbeat, true);
assert.strictEqual(mockClient.createMeasurement.mock.calls.length, 1);
assert.deepStrictEqual(mockClient.createMeasurement.mock.calls[0].arguments[0], {
type: "ping",
target: "example.com",
inProgressUpdates: false,
limit: 1,
locations: [{ magic: "North America" }],
measurementOptions: {
packets: 3,
protocol: "ICMP",
ipVersion: 4,
},
});
assert.deepStrictEqual(heartbeat, {
status: UP,
msg: "Ashburn (VA), US, NA, Amazon.com (AS14618), (aws-us-east-1) : OK",
ping: 2.169,
});
});
test("should handle failed ping with status failed", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
id: "2g8T7V3OwXG3JV6Y10011zF2v",
});
const measurement = createPingMeasurement();
measurement.results[0].result.status = "failed";
measurement.results[0].result.rawOutput = "Host unreachable";
const awaitResponse = createMockResponse(measurement);
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse);
const monitor = {
hostname: "unreachable.example.com",
location: "Europe",
ping_count: 3,
protocol: "ICMP",
};
const heartbeat = {
status: PENDING,
msg: "",
};
await monitorType.ping(mockClient, monitor, heartbeat, true);
assert.deepStrictEqual(heartbeat, {
status: DOWN,
msg: "Ashburn (VA), US, NA, Amazon.com (AS14618), (aws-us-east-1) : Failed: Host unreachable",
});
});
test("should handle API error on create measurement", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
error: {
type: "validation_error",
message: "Invalid target",
params: { target: "example.com" },
},
});
createResponse.ok = false;
createResponse.response.status = 400;
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
const monitor = {
hostname: "example.com",
location: "North America",
ping_count: 3,
protocol: "ICMP",
};
const heartbeat = {
status: PENDING,
msg: "",
};
await assert.rejects(monitorType.ping(mockClient, monitor, heartbeat, true), (error) => {
assert.deepStrictEqual(
error,
new Error("Failed to create measurement: validation_error Invalid target.\ntarget: example.com")
);
return true;
});
});
test("should handle API error on await measurement", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
id: "2g8T7V3OwXG3JV6Y10011zF2v",
});
const awaitResponse = createMockResponse({
error: {
type: "internal_error",
message: "Server error",
},
});
awaitResponse.ok = false;
awaitResponse.response.status = 400;
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse);
const monitor = {
hostname: "example.com",
location: "North America",
ping_count: 3,
protocol: "ICMP",
};
const heartbeat = {
status: PENDING,
msg: "",
};
await assert.rejects(monitorType.ping(mockClient, monitor, heartbeat, true), (error) => {
assert.deepStrictEqual(
error,
new Error("Failed to fetch measurement (2g8T7V3OwXG3JV6Y10011zF2v): internal_error Server error.")
);
return true;
});
});
});
describe("http", () => {
test("should handle successful HTTP request", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
id: "2g8T7V3OwXG3JV6Y10011zF2v",
});
const measurement = createHttpMeasurement();
const awaitResponse = createMockResponse(measurement);
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse);
const monitor = {
url: "https://example.com:444/api/test?test=1",
location: "North America",
method: "GET",
accepted_statuscodes_json: JSON.stringify(["200-299", "300-399"]),
headers: '{"Test-Header": "Test-Value"}',
ipFamily: "ipv4",
dns_resolve_server: "8.8.8.8",
auth_method: "basic",
basic_auth_user: "username",
basic_auth_pass: "password",
};
const heartbeat = {
status: PENDING,
msg: "",
ping: 0,
};
await monitorType.http(mockClient, monitor, heartbeat, true);
assert.strictEqual(mockClient.createMeasurement.mock.calls.length, 1);
const expectedToken = encodeBase64(monitor.basic_auth_user, monitor.basic_auth_pass);
assert.deepStrictEqual(mockClient.createMeasurement.mock.calls[0].arguments[0], {
type: "http",
target: "example.com",
inProgressUpdates: false,
limit: 1,
locations: [{ magic: "North America" }],
measurementOptions: {
request: {
host: "example.com",
path: "/api/test",
query: "test=1",
method: "GET",
headers: {
"Test-Header": "Test-Value",
Authorization: `Basic ${expectedToken}`,
},
},
port: 444,
protocol: "HTTPS",
ipVersion: 4,
resolver: "8.8.8.8",
},
});
assert.deepStrictEqual(heartbeat, {
status: UP,
msg: "New York (NY), US, NA, MASSIVEGRID (AS49683) : OK",
ping: 1440,
});
});
test("should handle failed HTTP request", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
id: "2g8T7V3OwXG3JV6Y10011zF2v",
});
const measurement = createHttpMeasurement();
measurement.results[0].result.status = "failed";
measurement.results[0].result.rawOutput = "Host unreachable";
const awaitResponse = createMockResponse(measurement);
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse);
const monitor = {
url: "https://example.com",
location: "North America",
method: "GET",
accepted_statuscodes_json: JSON.stringify(["200-299", "300-399"]),
headers: null,
};
const heartbeat = {
status: PENDING,
msg: "",
};
await monitorType.http(mockClient, monitor, heartbeat, true);
assert.deepStrictEqual(heartbeat, {
status: DOWN,
msg: "New York (NY), US, NA, MASSIVEGRID (AS49683) : Failed: Host unreachable",
});
});
test("should handle API error on create measurement", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
error: {
type: "validation_error",
message: "Invalid target",
params: { target: "example.com" },
},
});
createResponse.ok = false;
createResponse.response.status = 400;
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
const monitor = {
url: "https://example.com",
location: "North America",
method: "GET",
accepted_statuscodes_json: JSON.stringify(["200-299", "300-399"]),
headers: null,
};
const heartbeat = {
status: PENDING,
msg: "",
};
await assert.rejects(monitorType.http(mockClient, monitor, heartbeat, true), (error) => {
assert.deepStrictEqual(
error,
new Error("Failed to create measurement: validation_error Invalid target.\ntarget: example.com")
);
return true;
});
});
test("should handle API error on await measurement", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
id: "2g8T7V3OwXG3JV6Y10011zF2v",
});
const awaitResponse = createMockResponse({
error: {
type: "internal_error",
message: "Server error",
},
});
awaitResponse.ok = false;
awaitResponse.response.status = 400;
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse);
const monitor = {
url: "https://example.com",
location: "North America",
method: "GET",
accepted_statuscodes_json: JSON.stringify(["200-299", "300-399"]),
headers: null,
};
const heartbeat = {
status: PENDING,
msg: "",
};
await assert.rejects(monitorType.http(mockClient, monitor, heartbeat, true), (error) => {
assert.deepStrictEqual(
error,
new Error("Failed to fetch measurement (2g8T7V3OwXG3JV6Y10011zF2v): internal_error Server error.")
);
return true;
});
});
test("should handle invalid status code", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
id: "2g8T7V3OwXG3JV6Y10011zF2v",
});
const measurement = createHttpMeasurement();
measurement.results[0].result.rawOutput = "RAW OUTPUT";
const awaitResponse = createMockResponse(measurement);
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse);
const monitor = {
url: "https://example.com/api/test",
location: "North America",
method: "GET",
accepted_statuscodes_json: JSON.stringify(["200-299"]),
headers: null,
};
const heartbeat = {
status: PENDING,
msg: "",
ping: 0,
};
await monitorType.http(mockClient, monitor, heartbeat, true);
assert.deepStrictEqual(heartbeat, {
status: DOWN,
msg: "New York (NY), US, NA, MASSIVEGRID (AS49683) : Status code 301 not accepted. Output: RAW OUTPUT",
ping: 1440,
});
});
test("should handle keyword check (keyword present)", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
id: "2g8T7V3OwXG3JV6Y10011zF2v",
});
const measurement = createHttpMeasurement();
measurement.results[0].result.rawOutput = "Response body with KEYWORD word";
const awaitResponse = createMockResponse(measurement);
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse);
const monitor = {
url: "https://example.com",
location: "North America",
protocol: "HTTPS",
accepted_statuscodes_json: JSON.stringify(["300-399"]),
keyword: "KEYWORD",
invertKeyword: false,
};
const heartbeat = {
status: PENDING,
msg: "",
};
await monitorType.http(mockClient, monitor, heartbeat, true);
assert.deepStrictEqual(heartbeat, {
status: UP,
msg: "New York (NY), US, NA, MASSIVEGRID (AS49683) : 301 - Moved Permanently, keyword is found",
ping: 1440,
});
});
test("should handle keyword check (keyword not present)", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
id: "2g8T7V3OwXG3JV6Y10011zF2v",
});
const measurement = createHttpMeasurement();
measurement.results[0].result.rawOutput = "Response body with KEYWORD word";
const awaitResponse = createMockResponse(measurement);
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse);
const monitor = {
url: "https://example.com",
location: "North America",
protocol: "HTTPS",
accepted_statuscodes_json: JSON.stringify(["300-399"]),
keyword: "MISSING_KEYWORD",
invertKeyword: false,
};
const heartbeat = {
status: PENDING,
msg: "",
};
await assert.rejects(monitorType.http(mockClient, monitor, heartbeat, true), (error) => {
assert.deepStrictEqual(
error,
new Error(
"New York (NY), US, NA, MASSIVEGRID (AS49683) : 301 - Moved Permanently, but keyword is not in [Response body with KEYWORD word]"
)
);
return true;
});
});
test("should handle inverted keyword check", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
id: "2g8T7V3OwXG3JV6Y10011zF2v",
});
const measurement = createHttpMeasurement();
measurement.results[0].result.rawOutput = "Response body with KEYWORD word";
const awaitResponse = createMockResponse(measurement);
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse);
const monitor = {
url: "https://example.com",
location: "North America",
protocol: "HTTPS",
accepted_statuscodes_json: JSON.stringify(["300-399"]),
keyword: "ERROR",
invertKeyword: true,
};
const heartbeat = {
status: PENDING,
msg: "",
};
await monitorType.http(mockClient, monitor, heartbeat, true);
assert.deepStrictEqual(heartbeat, {
status: UP,
msg: "New York (NY), US, NA, MASSIVEGRID (AS49683) : 301 - Moved Permanently, keyword not found",
ping: 1440,
});
});
test("should handle JSON query check (valid)", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
id: "2g8T7V3OwXG3JV6Y10011zF2v",
});
const measurement = createHttpMeasurement();
measurement.results[0].result.rawOutput = JSON.stringify({
status: "success",
value: 42,
});
const awaitResponse = createMockResponse(measurement);
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse);
const monitor = {
url: "https://api.example.com/status",
location: "North America",
protocol: "HTTPS",
accepted_statuscodes_json: JSON.stringify(["300-399"]),
jsonPath: "$.status",
jsonPathOperator: "==",
expectedValue: "success",
};
const heartbeat = {
status: PENDING,
msg: "",
};
await monitorType.http(mockClient, monitor, heartbeat, true);
assert.deepStrictEqual(heartbeat, {
status: UP,
msg: "New York (NY), US, NA, MASSIVEGRID (AS49683) : JSON query passes (comparing success == success)",
ping: 1440,
});
});
test("should handle JSON query check (invalid)", async () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const mockClient = createGlobalpingClientMock();
const createResponse = createMockResponse({
id: "2g8T7V3OwXG3JV6Y10011zF2v",
});
const measurement = createHttpMeasurement();
measurement.results[0].result.rawOutput = JSON.stringify({
status: "failed",
value: 42,
});
const awaitResponse = createMockResponse(measurement);
mockClient.createMeasurement.mock.mockImplementation(() => createResponse);
mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse);
const monitor = {
url: "https://api.example.com/status",
location: "North America",
protocol: "HTTPS",
accepted_statuscodes_json: JSON.stringify(["300-399"]),
jsonPath: "$.status",
jsonPathOperator: "==",
expectedValue: "success",
};
const heartbeat = {
status: PENDING,
msg: "",
};
await assert.rejects(monitorType.http(mockClient, monitor, heartbeat, true), (error) => {
assert.deepStrictEqual(
error,
new Error(
"New York (NY), US, NA, MASSIVEGRID (AS49683) : JSON query does not pass (comparing failed == success)"
)
);
return true;
});
});
});
describe("helper methods", () => {
test("formatProbeLocation should format location correctly", () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const probe = {
city: "New York",
state: "NY",
country: "US",
continent: "NA",
network: "Amazon.com",
asn: 14618,
tags: ["aws-us-east-1", "datacenter"],
};
const result = monitorType.formatProbeLocation(probe);
assert.strictEqual(result, "New York (NY), US, NA, Amazon.com (AS14618), (aws-us-east-1)");
});
test("formatProbeLocation should handle missing state", () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const probe = {
city: "London",
state: null,
country: "GB",
continent: "EU",
network: "Example Network",
asn: 12345,
tags: [],
};
const result = monitorType.formatProbeLocation(probe);
assert.strictEqual(result, "London, GB, EU, Example Network (AS12345)");
});
test("formatResponse should combine location and text", () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const probe = {
city: "Tokyo",
state: null,
country: "JP",
continent: "AS",
network: "Example ISP",
asn: 54321,
tags: [],
};
const result = monitorType.formatResponse(probe, "Test message");
assert.strictEqual(result, "Tokyo, JP, AS, Example ISP (AS54321) : Test message");
});
test("formatApiError should format error with params", () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const error = {
type: "validation_error",
message: "Invalid request",
params: {
field: "target",
value: "invalid",
},
};
const result = monitorType.formatApiError(error);
assert.strictEqual(result, "validation_error Invalid request.\nfield: target\nvalue: invalid");
});
test("formatApiError should format error without params", () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const error = {
type: "internal_error",
message: "Server error",
};
const result = monitorType.formatApiError(error);
assert.strictEqual(result, "internal_error Server error.");
});
test("formatTooManyRequestsError with API token", () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const result = monitorType.formatTooManyRequestsError(true);
assert.strictEqual(
result,
"You have run out of credits. Get higher limits by sponsoring us or hosting probes. Learn more at https://dash.globalping.io?view=add-credits."
);
});
test("formatTooManyRequestsError without API token", () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const result = monitorType.formatTooManyRequestsError(false);
assert.strictEqual(
result,
"You have run out of credits. Get higher limits by creating an account. Sign up at https://dash.globalping.io?view=add-credits."
);
});
test("getBasicAuthHeader should return empty for non-basic auth", () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const monitor = {
auth_method: "none",
};
const result = monitorType.getBasicAuthHeader(monitor);
assert.deepStrictEqual(result, {});
});
test("getBasicAuthHeader should return Authorization header", () => {
const monitorType = new GlobalpingMonitorType("test-agent/1.0");
const monitor = {
auth_method: "basic",
basic_auth_user: "testuser",
basic_auth_pass: "testpass",
};
const result = monitorType.getBasicAuthHeader(monitor);
const expectedToken = encodeBase64(monitor.basic_auth_user, monitor.basic_auth_pass);
assert.strictEqual(result.Authorization, `Basic ${expectedToken}`);
});
});
});
/**
* Reusable mock factory for Globalping client
* @returns {object} Mocked Globalping client
*/
function createGlobalpingClientMock() {
return {
createMeasurement: mock.fn(),
awaitMeasurement: mock.fn(),
};
}
/**
* Reusable mock factory for Globalping response
* @param {object} data Response data
* @returns {object} Mocked Globalping response
*/
function createMockResponse(data) {
return {
ok: true,
response: {
status: 200,
},
data,
};
}
/**
* Creates a successful ping measurement response
* @returns {object} Mock measurement response
*/
function createPingMeasurement() {
return {
id: "2g8T7V3OwXG3JV6Y10011zF2v",
type: "ping",
status: "finished",
createdAt: "2025-11-05T08:25:33.173Z",
updatedAt: "2025-11-05T08:25:34.750Z",
target: "google.com",
probesCount: 1,
locations: [{ magic: "us-east-1" }],
results: [
{
probe: {
continent: "NA",
region: "Northern America",
country: "US",
state: "VA",
city: "Ashburn",
asn: 14618,
longitude: -77.49,
latitude: 39.04,
network: "Amazon.com",
tags: [
"aws-us-east-1",
"aws",
"datacenter-network",
"u-cloudlookingglass:aws-us-east-1-use1-az6",
"u-cloudlookingglass:aws-us-east-1-use1-az6-net",
],
resolvers: ["private"],
},
result: {
status: "finished",
rawOutput:
"PING (142.251.16.100) 56(84) bytes of data.\n64 bytes from bl-in-f100.1e100.net (142.251.16.100): icmp_seq=1 ttl=106 time=2.07 ms\n64 bytes from bl-in-f100.1e100.net (142.251.16.100): icmp_seq=2 ttl=106 time=2.08 ms\n64 bytes from bl-in-f100.1e100.net (142.251.16.100): icmp_seq=3 ttl=106 time=2.35 ms\n\n--- ping statistics ---\n3 packets transmitted, 3 received, 0% packet loss, time 1002ms\nrtt min/avg/max/mdev = 2.073/2.169/2.351/0.128 ms",
resolvedAddress: "142.251.16.100",
resolvedHostname: "bl-in-f100.1e100.net",
timings: [
{ ttl: 106, rtt: 2.07 },
{ ttl: 106, rtt: 2.08 },
{ ttl: 106, rtt: 2.35 },
],
stats: {
min: 2.073,
max: 2.351,
avg: 2.169,
total: 3,
loss: 0,
rcv: 3,
drop: 0,
},
},
},
],
};
}
/**
* Creates a successful HTTP measurement response
* @returns {object} Mock measurement response
*/
function createHttpMeasurement() {
return {
id: "2m6DeD067jeT6licX0011zF2x",
type: "http",
status: "finished",
createdAt: "2025-11-05T08:27:29.034Z",
updatedAt: "2025-11-05T08:27:30.718Z",
target: "google.com",
probesCount: 1,
locations: [{ magic: "New York" }],
results: [
{
probe: {
continent: "NA",
region: "Northern America",
country: "US",
state: "NY",
city: "New York",
asn: 49683,
longitude: -74.01,
latitude: 40.71,
network: "MASSIVEGRID",
tags: ["datacenter-network", "u-gbzret4d"],
resolvers: ["private"],
},
result: {
status: "finished",
resolvedAddress: "209.85.201.101",
headers: {
location: "https://www.google.com/",
"content-type": "text/html; charset=UTF-8",
"content-security-policy-report-only":
"object-src 'none';base-uri 'self';script-src 'nonce-Eft2LKpM01f69RvQoV6QJA' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp",
date: "Wed, 05 Nov 2025 08:27:30 GMT",
expires: "Fri, 05 Dec 2025 08:27:30 GMT",
"cache-control": "public, max-age=2592000",
server: "gws",
"content-length": "220",
"x-xss-protection": "0",
"x-frame-options": "SAMEORIGIN",
"alt-svc": 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000',
connection: "close",
},
rawHeaders:
"Location: https://www.google.com/\nContent-Type: text/html; charset=UTF-8\nContent-Security-Policy-Report-Only: object-src 'none';base-uri 'self';script-src 'nonce-Eft2LKpM01f69RvQoV6QJA' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp\nDate: Wed, 05 Nov 2025 08:27:30 GMT\nExpires: Fri, 05 Dec 2025 08:27:30 GMT\nCache-Control: public, max-age=2592000\nServer: gws\nContent-Length: 220\nX-XSS-Protection: 0\nX-Frame-Options: SAMEORIGIN\nAlt-Svc: h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000\nConnection: close",
rawBody: null,
rawOutput:
"HTTP/1.1 301\nLocation: https://www.google.com/\nContent-Type: text/html; charset=UTF-8\nContent-Security-Policy-Report-Only: object-src 'none';base-uri 'self';script-src 'nonce-Eft2LKpM01f69RvQoV6QJA' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp\nDate: Wed, 05 Nov 2025 08:27:30 GMT\nExpires: Fri, 05 Dec 2025 08:27:30 GMT\nCache-Control: public, max-age=2592000\nServer: gws\nContent-Length: 220\nX-XSS-Protection: 0\nX-Frame-Options: SAMEORIGIN\nAlt-Svc: h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000\nConnection: close",
truncated: false,
statusCode: 301,
statusCodeName: "Moved Permanently",
timings: {
total: 1440,
download: 1,
firstByte: 1391,
dns: 9,
tls: 22,
tcp: 16,
},
},
},
],
};
}