diff --git a/server/monitor-types/globalping.js b/server/monitor-types/globalping.js index d6b985938..eeea6de37 100644 --- a/server/monitor-types/globalping.js +++ b/server/monitor-types/globalping.js @@ -1,7 +1,7 @@ const { MonitorType } = require("./monitor-type"); const { Globalping, IpVersion } = require("globalping"); const { Settings } = require("../settings"); -const { log, UP, DOWN, evaluateJsonQuery } = require("../../src/util"); +const { log, UP, evaluateJsonQuery } = require("../../src/util"); const { checkStatusCode, getOidcTokenClientCredentials, @@ -50,6 +50,9 @@ class GlobalpingMonitorType extends MonitorType { case "http": await this.http(client, monitor, heartbeat, hasAPIToken); break; + case "dns": + await this.dns(client, monitor, heartbeat, hasAPIToken, R); + break; } } @@ -107,15 +110,11 @@ class GlobalpingMonitorType extends MonitorType { const result = measurement.data.results[0].result; if (result.status === "failed") { - heartbeat.msg = this.formatResponse(probe, `Failed: ${result.rawOutput}`); - heartbeat.status = DOWN; - return; + throw new Error(this.formatResponse(probe, `Failed: ${result.rawOutput}`)); } if (!result.timings?.length) { - heartbeat.msg = this.formatResponse(probe, `Failed: ${result.rawOutput}`); - heartbeat.status = DOWN; - return; + throw new Error(this.formatResponse(probe, `Failed: ${result.rawOutput}`)); } heartbeat.ping = result.stats.avg || 0; @@ -208,20 +207,15 @@ class GlobalpingMonitorType extends MonitorType { const result = measurement.data.results[0].result; if (result.status === "failed") { - heartbeat.msg = this.formatResponse(probe, `Failed: ${result.rawOutput}`); - heartbeat.status = DOWN; - return; + throw new Error(this.formatResponse(probe, `Failed: ${result.rawOutput}`)); } 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}` + throw new Error( + 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}`); @@ -244,13 +238,135 @@ class GlobalpingMonitorType extends MonitorType { heartbeat.status = UP; } + /** + * Handles DNS 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. + * @param {R} redbean - The redbean object. + * @returns {Promise} A promise that resolves when the HTTP monitor is handled. + */ + async dns(client, monitor, heartbeat, hasAPIToken, redbean) { + const opts = { + type: "dns", + target: monitor.hostname, + inProgressUpdates: false, + limit: 1, + locations: [{ magic: monitor.location }], + measurementOptions: { + query: { + type: monitor.dns_resolve_type, + }, + port: monitor.port, + protocol: monitor.protocol, + }, + }; + + 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); + log.debug("monitor", `Globalping ${JSON.stringify(res)}`); + 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") { + throw new Error(this.formatResponse(probe, `Failed: ${result.rawOutput}`)); + } + + let dnsMessage = (result.answers || []).map((answer) => answer.value).join(" | "); + const values = (result.answers || []).map((answer) => answer.value); + + let recordMatched = true; + + // keyword + if (monitor.keyword) { + recordMatched = this.checkDNSRecordValueMatch(monitor, values, monitor.keyword); + } + + if (monitor.dns_last_result !== dnsMessage && dnsMessage !== undefined) { + await redbean.exec("UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", [dnsMessage, monitor.id]); + } + + heartbeat.ping = result.timings.total || 0; + + if (!dnsMessage) { + dnsMessage = `No records found. ${result.statusCodeName}`; + } + + if (!recordMatched) { + throw new Error(this.formatResponse(probe, "No record matched. " + dnsMessage)); + } + + heartbeat.msg = this.formatResponse(probe, dnsMessage); + heartbeat.status = UP; + } + /** * Handles keyword for HTTP monitors. * @param {Monitor} monitor - The monitor object. + * @param {Array} values - The values to search for. + * @param {string} keyword - The keyword to search for. + * @returns {boolean} True if the regex matches, false otherwise. + */ + checkDNSRecordValueMatch(monitor, values, keyword) { + const regex = new RegExp(keyword, "i"); + + switch (monitor.dns_resolve_type) { + case "A": + case "AAAA": + case "ANY": + case "CNAME": + case "DNSKEY": + case "DS": + case "HTTPS": + case "MX": + case "NS": + case "NSEC": + case "PTR": + case "RRSIG": + case "SOA": + case "SRV": + case "SVCB": + case "TXT": + return values.some((record) => regex.test(record)); + } + + return false; + } + + /** + * Handles keyword search 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. + * @returns {Promise} */ async handleKeywordForHTTP(monitor, heartbeat, result, probe) { let data = result.rawOutput; diff --git a/server/notification-providers/notification-provider.js b/server/notification-providers/notification-provider.js index 2a91ca1e4..554c31fda 100644 --- a/server/notification-providers/notification-provider.js +++ b/server/notification-providers/notification-provider.js @@ -48,6 +48,7 @@ class NotificationProvider { case "globalping": switch (monitorJSON["subtype"]) { case "ping": + case "dns": return monitorJSON["hostname"]; case "http": return monitorJSON["url"]; diff --git a/server/server.js b/server/server.js index b919c6271..a1c0eb7e8 100644 --- a/server/server.js +++ b/server/server.js @@ -745,7 +745,11 @@ 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", "responsecheck"]; + const frontendOnlyProperties = [ + "humanReadableInterval", + "globalpingdnsresolvetypeoptions", + "responsecheck", + ]; for (const prop of frontendOnlyProperties) { if (prop in monitor) { delete monitor[prop]; diff --git a/src/lang/en.json b/src/lang/en.json index 764bffb4e..a0259dd86 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -1349,7 +1349,7 @@ "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}.", + "GlobalpingMonitorDescription": "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}. Check the {docs} for more information.", "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.", @@ -1357,6 +1357,8 @@ "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.", + "RecordMatch": "Record value match", + "RegexMatch": "Enter a regex to match the record value", "Resolver Server": "Resolver Server", "Protocol": "Protocol", "account settings": "account settings", diff --git a/src/pages/Details.vue b/src/pages/Details.vue index 5be019870..2a4bb867e 100644 --- a/src/pages/Details.vue +++ b/src/pages/Details.vue @@ -44,6 +44,12 @@ {{ $t("Location") }}: {{ monitor.location }}
+ + [{{ monitor.dns_resolve_type }}] +
+ {{ $t("Last Result") }}: + {{ monitor.dns_last_result }} +

diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 9c2f76ec5..7c0eda1fb 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -10,13 +10,21 @@ +
@@ -122,6 +130,7 @@ > +
@@ -472,7 +481,7 @@ + @@ -2829,6 +2903,7 @@ export default { acceptedStatusCodeOptions: [], acceptedWebsocketCodeOptions: [], dnsresolvetypeOptions: [], + globalpingdnsresolvetypeoptions: [], kafkaSaslMechanismOptions: [], gameList: null, connectionStringTemplates: { @@ -3310,14 +3385,26 @@ message HealthCheckResponse { if (!oldSubtype && !this.monitor.protocol) { if (newSubtype === "ping") { this.monitor.protocol = "ICMP"; + } else if (newSubtype === "dns") { + this.monitor.protocol = "UDP"; } else if (newSubtype === "http") { this.monitor.protocol = null; } } + + if (!oldSubtype && this.monitor.port === undefined) { + if (newSubtype === "dns") { + this.monitor.port = "53"; + } + } + if (newSubtype !== oldSubtype) { if (newSubtype === "ping") { this.monitor.protocol = "ICMP"; this.monitor.port = "80"; + } else if (newSubtype === "dns") { + this.monitor.protocol = "UDP"; + this.monitor.port = "53"; } else if (newSubtype === "http") { this.monitor.protocol = null; } @@ -3364,6 +3451,24 @@ message HealthCheckResponse { let acceptedWebsocketCodeOptions = []; let dnsresolvetypeOptions = ["A", "AAAA", "CAA", "CNAME", "MX", "NS", "PTR", "SOA", "SRV", "TXT"]; + const globalpingdnsresolvetypeoptions = [ + "A", + "AAAA", + "ANY", + "CNAME", + "DNSKEY", + "DS", + "HTTPS", + "MX", + "NS", + "NSEC", + "PTR", + "RRSIG", + "SOA", + "SRV", + "SVCB", + "TXT", + ]; let kafkaSaslMechanismOptions = ["None", "plain", "scram-sha-256", "scram-sha-512", "aws"]; @@ -3378,6 +3483,7 @@ message HealthCheckResponse { this.acceptedWebsocketCodeOptions = acceptedWebsocketCodeOptions; this.acceptedStatusCodeOptions = acceptedStatusCodeOptions; this.dnsresolvetypeOptions = dnsresolvetypeOptions; + this.globalpingdnsresolvetypeoptions = globalpingdnsresolvetypeoptions; this.kafkaSaslMechanismOptions = kafkaSaslMechanismOptions; }, methods: { diff --git a/test/backend-test/test-globalping.js b/test/backend-test/test-globalping.js index 143533f2f..46b2b8344 100644 --- a/test/backend-test/test-globalping.js +++ b/test/backend-test/test-globalping.js @@ -1,7 +1,7 @@ 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"); +const { UP, PENDING } = require("../../src/util"); describe("GlobalpingMonitorType", () => { const { GlobalpingMonitorType } = require("../../server/monitor-types/globalping"); @@ -82,11 +82,12 @@ describe("GlobalpingMonitorType", () => { 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", + await assert.rejects(monitorType.ping(mockClient, monitor, heartbeat, true), (error) => { + assert.deepStrictEqual( + error, + new Error("Ashburn (VA), US, NA, Amazon.com (AS14618), (aws-us-east-1) : Failed: Host unreachable") + ); + return true; }); }); @@ -260,11 +261,12 @@ describe("GlobalpingMonitorType", () => { msg: "", }; - await monitorType.http(mockClient, monitor, heartbeat, true); - - assert.deepStrictEqual(heartbeat, { - status: DOWN, - msg: "New York (NY), US, NA, MASSIVEGRID (AS49683) : Failed: Host unreachable", + await assert.rejects(monitorType.http(mockClient, monitor, heartbeat, true), (error) => { + assert.deepStrictEqual( + error, + new Error("New York (NY), US, NA, MASSIVEGRID (AS49683) : Failed: Host unreachable") + ); + return true; }); }); @@ -372,13 +374,18 @@ describe("GlobalpingMonitorType", () => { 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, + await assert.rejects(monitorType.http(mockClient, monitor, heartbeat, true), (error) => { + assert.deepStrictEqual( + error, + new Error( + "New York (NY), US, NA, MASSIVEGRID (AS49683) : Status code 301 not accepted. Output: RAW OUTPUT" + ) + ); + return true; }); + + // heartbeat.ping should still be set before the error is thrown + assert.strictEqual(heartbeat.ping, 1440); }); test("should handle keyword check (keyword present)", async () => { @@ -574,6 +581,271 @@ describe("GlobalpingMonitorType", () => { }); }); + describe("dns", () => { + test("should handle successful dns", async () => { + const monitorType = new GlobalpingMonitorType("test-agent/1.0"); + const mockClient = createGlobalpingClientMock(); + const createResponse = createMockResponse({ + id: "2g8T7V3OwXG3JV6Y10011zF2v", + }); + const measurement = createDnsMeasurement(); + const awaitResponse = createMockResponse(measurement); + + mockClient.createMeasurement.mock.mockImplementation(() => createMockResponse(createResponse)); + mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse); + + const redbeanMock = createRedbeanMock(); + redbeanMock.exec.mock.mockImplementation(() => Promise.resolve()); + + const monitor = { + id: "1", + hostname: "example.com", + location: "us-east-1", + dns_resolve_type: "A", + port: 53, + protocol: "udp", + }; + + const heartbeat = { + status: PENDING, + msg: "", + ping: null, + }; + + await monitorType.dns(mockClient, monitor, heartbeat, false, redbeanMock); + + assert.strictEqual(mockClient.createMeasurement.mock.calls.length, 1); + assert.deepStrictEqual(mockClient.createMeasurement.mock.calls[0].arguments[0], { + type: "dns", + target: "example.com", + inProgressUpdates: false, + limit: 1, + locations: [{ magic: "us-east-1" }], + measurementOptions: { + query: { type: "A" }, + port: 53, + protocol: "udp", + }, + }); + + assert.deepStrictEqual(heartbeat, { + status: UP, + msg: "New York (NY), US, NA, MASSIVEGRID (AS49683) : 93.184.216.34", + ping: 25, + }); + + assert.strictEqual(redbeanMock.exec.mock.calls.length, 1); + assert.deepStrictEqual(redbeanMock.exec.mock.calls[0].arguments, [ + "UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", + ["93.184.216.34", "1"], + ]); + }); + + test("should handle failed dns with status failed", async () => { + const monitorType = new GlobalpingMonitorType("test-agent/1.0"); + const mockClient = createGlobalpingClientMock(); + const createResponse = createMockResponse({ + id: "2g8T7V3OwXG3JV6Y10011zF2v", + }); + const measurement = createDnsMeasurement(); + measurement.results[0].result.status = "failed"; + measurement.results[0].result.rawOutput = "NXDOMAIN"; + const awaitResponse = createMockResponse(measurement); + + mockClient.createMeasurement.mock.mockImplementation(() => createMockResponse(createResponse)); + mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse); + + const monitor = { + hostname: "nonexistent.example.com", + location: "us-east-1", + dns_resolve_type: "A", + port: 53, + }; + + const heartbeat = { + status: PENDING, + msg: "", + }; + + await assert.rejects( + async () => { + await monitorType.dns(mockClient, monitor, heartbeat, false); + }, + (error) => { + assert.strictEqual( + error.message, + "New York (NY), US, NA, MASSIVEGRID (AS49683) : Failed: NXDOMAIN" + ); + return true; + } + ); + }); + + 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: "us-east-1", + dns_resolve_type: "A", + port: 53, + }; + + const heartbeat = { + status: PENDING, + msg: "", + }; + + await assert.rejects(monitorType.dns(mockClient, monitor, heartbeat, false), (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: "us-east-1", + dns_resolve_type: "A", + port: 53, + }; + + const heartbeat = { + status: PENDING, + msg: "", + }; + + await assert.rejects(monitorType.dns(mockClient, monitor, heartbeat, false), (error) => { + assert.deepStrictEqual( + error, + new Error("Failed to fetch measurement (2g8T7V3OwXG3JV6Y10011zF2v): internal_error Server error.") + ); + return true; + }); + }); + + test("should handle regex matched", async () => { + const monitorType = new GlobalpingMonitorType("test-agent/1.0"); + const mockClient = createGlobalpingClientMock(); + const createResponse = { + id: "2g8T7V3OwXG3JV6Y10011zF2v", + }; + const measurement = createDnsMeasurement(); + const awaitResponse = createMockResponse(measurement); + + mockClient.createMeasurement.mock.mockImplementation(() => createMockResponse(createResponse)); + mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse); + + const redbeanMock = createRedbeanMock(); + redbeanMock.exec.mock.mockImplementation(() => Promise.resolve()); + + const monitor = { + id: "1", + hostname: "example.com", + location: "us-east-1", + dns_resolve_type: "A", + port: 53, + protocol: "udp", + keyword: "93\\.184\\.216\\.34", + }; + + const heartbeat = { + status: PENDING, + msg: "", + ping: null, + }; + + await monitorType.dns(mockClient, monitor, heartbeat, false, redbeanMock); + + assert.deepStrictEqual(heartbeat, { + status: UP, + msg: "New York (NY), US, NA, MASSIVEGRID (AS49683) : 93.184.216.34", + ping: 25, + }); + + assert.deepStrictEqual(redbeanMock.exec.mock.calls[0].arguments, [ + "UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", + ["93.184.216.34", "1"], + ]); + }); + + test("should handle regex not matched", async () => { + const monitorType = new GlobalpingMonitorType("test-agent/1.0"); + const mockClient = createGlobalpingClientMock(); + const createResponse = { + id: "2g8T7V3OwXG3JV6Y10011zF2v", + }; + const measurement = createDnsMeasurement(); + const awaitResponse = createMockResponse(measurement); + + mockClient.createMeasurement.mock.mockImplementation(() => createMockResponse(createResponse)); + mockClient.awaitMeasurement.mock.mockImplementation(() => awaitResponse); + + const redbeanMock = createRedbeanMock(); + redbeanMock.exec.mock.mockImplementation(() => Promise.resolve()); + + const monitor = { + id: "1", + hostname: "example.com", + location: "us-east-1", + dns_resolve_type: "A", + port: 53, + protocol: "udp", + keyword: "192\\.168\\.1\\.1", + }; + + const heartbeat = { + status: PENDING, + msg: "", + }; + + await assert.rejects(monitorType.dns(mockClient, monitor, heartbeat, false, redbeanMock), (error) => { + assert.deepStrictEqual( + error, + new Error("New York (NY), US, NA, MASSIVEGRID (AS49683) : No record matched. 93.184.216.34") + ); + return true; + }); + + assert.deepStrictEqual(redbeanMock.exec.mock.calls[0].arguments, [ + "UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", + ["93.184.216.34", "1"], + ]); + }); + }); + describe("helper methods", () => { test("formatProbeLocation should format location correctly", () => { const monitorType = new GlobalpingMonitorType("test-agent/1.0"); @@ -722,6 +994,16 @@ function createGlobalpingClientMock() { }; } +/** + * Reusable mock factory for RedBean + * @returns {object} Mocked RedBean + */ +function createRedbeanMock() { + return { + exec: mock.fn(), + }; +} + /** * Reusable mock factory for Globalping response * @param {object} data Response data @@ -866,3 +1148,53 @@ function createHttpMeasurement() { ], }; } + +/** + * Creates a successful DNS measurement response + * @returns {object} Mock measurement response + */ +function createDnsMeasurement() { + return { + id: "2g8T7V3OwXG3JV6Y10011zF2v", + type: "dns", + status: "finished", + createdAt: "2025-11-05T08:30:00.000Z", + updatedAt: "2025-11-05T08:30:01.000Z", + target: "example.com", + probesCount: 1, + locations: [{ magic: "us-east-1" }], + 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", + rawOutput: ";; ANSWER SECTION:\nexample.com.\t\t86400\tIN\tA\t93.184.216.34", + answers: [ + { + name: "example.com.", + type: "A", + ttl: 86400, + class: "IN", + value: "93.184.216.34", + }, + ], + timings: { + total: 25, + }, + }, + }, + ], + }; +}