Blend json-query and snmp monitors

Utilizes the JSON Query library to handle comparison logic.
This commit is contained in:
Matt Visnovsky 2024-06-05 16:09:53 -06:00
parent 2d2c1866df
commit efb1642e3c
6 changed files with 152 additions and 70 deletions

View file

@ -2,7 +2,7 @@ const dayjs = require("dayjs");
const axios = require("axios"); const axios = require("axios");
const { Prometheus } = require("../prometheus"); const { Prometheus } = require("../prometheus");
const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND,
SQL_DATETIME_FORMAT SQL_DATETIME_FORMAT, evaluateJsonQuery
} = require("../../src/util"); } = require("../../src/util");
const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery, const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery,
redisPingAsync, mongodbPing, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal redisPingAsync, mongodbPing, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal
@ -17,7 +17,6 @@ const apicache = require("../modules/apicache");
const { UptimeKumaServer } = require("../uptime-kuma-server"); const { UptimeKumaServer } = require("../uptime-kuma-server");
const { DockerHost } = require("../docker"); const { DockerHost } = require("../docker");
const Gamedig = require("gamedig"); const Gamedig = require("gamedig");
const jsonata = require("jsonata");
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const crypto = require("crypto"); const crypto = require("crypto");
const { UptimeCalculator } = require("../uptime-calculator"); const { UptimeCalculator } = require("../uptime-calculator");
@ -610,15 +609,13 @@ class Monitor extends BeanModel {
} }
} }
let expression = jsonata(this.jsonPath); const result = await evaluateJsonQuery(data, this.jsonPath, this.jsonPathOperator, this.expectedValue);
let result = await expression.evaluate(data); if (result) {
if (result.toString() === this.expectedValue) {
bean.msg += ", expected value is found"; bean.msg += ", expected value is found";
bean.status = UP; bean.status = UP;
} else { } else {
throw new Error(bean.msg + ", but value is not equal to expected value, value was: [" + result + "]"); throw new Error(`${bean.msg}, but value is not equal to expected value, value was: [${result}]`);
} }
} }

View file

@ -1,7 +1,6 @@
const { MonitorType } = require("./monitor-type"); const { MonitorType } = require("./monitor-type");
const { UP, DOWN, log } = require("../../src/util"); const { UP, DOWN, log, evaluateJsonQuery } = require("../../src/util");
const snmp = require("net-snmp"); const snmp = require("net-snmp");
const jsonata = require("jsonata");
class SNMPMonitorType extends MonitorType { class SNMPMonitorType extends MonitorType {
name = "snmp"; name = "snmp";
@ -45,46 +44,23 @@ class SNMPMonitorType extends MonitorType {
// We restrict querying to one OID per monitor, therefore `varbinds[0]` will always contain the value we're interested in. // We restrict querying to one OID per monitor, therefore `varbinds[0]` will always contain the value we're interested in.
const value = varbinds[0].value; const value = varbinds[0].value;
// Check if inputs are numeric. If not, re-parse as strings. This ensures comparisons are handled correctly. const result = await evaluateJsonQuery(value, monitor.jsonPath, monitor.jsonPathOperator, monitor.expectedValue);
const expectedValue = isNaN(monitor.expectedValue) ? monitor.expectedValue.toString() : parseFloat(monitor.expectedValue);
let snmpResponse = isNaN(value) ? value.toString() : parseFloat(value);
let jsonQueryExpression;
switch (monitor.jsonPathOperator) {
case ">":
case ">=":
case "<":
case "<=":
jsonQueryExpression = `$.value ${monitor.jsonPathOperator} $.control`;
break;
case "==":
jsonQueryExpression = "$string($.value) = $string($.control)";
break;
case "contains":
jsonQueryExpression = "$contains($string($.value), $string($.control))";
break;
default:
throw new Error(`Invalid condition ${monitor.jsonPathOperator}`);
}
const expression = jsonata(jsonQueryExpression);
const evaluation = await expression.evaluate({
value: snmpResponse,
control: expectedValue
});
heartbeat.status = result ? UP : DOWN; heartbeat.status = result ? UP : DOWN;
heartbeat.msg = `SNMP value ${result ? "passes" : "does not pass"} comparison: ${snmpValue} ${monitor.snmpCondition} ${snmpControlValue}`; heartbeat.msg = `SNMP value ${result ? "passes" : "does not pass"} `;
heartbeat.msg += (monitor.jsonPathOperator === "custom")
? `custom query. Query result: ${result}. Expected Value: ${monitor.expectedValue}.`
: `comparison: ${value.toString()} ${monitor.jsonPathOperator} ${monitor.expectedValue}.`;
} catch (err) { } catch (err) {
heartbeat.status = DOWN; heartbeat.status = DOWN;
heartbeat.msg = `SNMP Error: ${err.message}`; heartbeat.msg = `Error: ${err.message}`;
} finally { } finally {
if (session) { if (session) {
session.close(); session.close();
} }
} }
} }
} }
module.exports = { module.exports = {

View file

@ -59,10 +59,10 @@
"Keyword": "Keyword", "Keyword": "Keyword",
"Invert Keyword": "Invert Keyword", "Invert Keyword": "Invert Keyword",
"Expected Value": "Expected Value", "Expected Value": "Expected Value",
"Json Query": "Json Query", "Custom Json Query Expression": "Custom Json Query Expression",
"Friendly Name": "Friendly Name", "Friendly Name": "Friendly Name",
"URL": "URL", "URL": "URL",
"Hostname": "Hostname", "Hostname or IP Address": "Hostname or IP Address",
"Host URL": "Host URL", "Host URL": "Host URL",
"locally configured mail transfer agent": "locally configured mail transfer agent", "locally configured mail transfer agent": "locally configured mail transfer agent",
"Either enter the hostname of the server you want to connect to or localhost if you intend to use a locally configured mail transfer agent": "Either enter the hostname of the server you want to connect to or {localhost} if you intend to use a {local_mta}", "Either enter the hostname of the server you want to connect to or localhost if you intend to use a locally configured mail transfer agent": "Either enter the hostname of the server you want to connect to or {localhost} if you intend to use a {local_mta}",
@ -577,7 +577,7 @@
"notificationDescription": "Notifications must be assigned to a monitor to function.", "notificationDescription": "Notifications must be assigned to a monitor to function.",
"keywordDescription": "Search keyword in plain HTML or JSON response. The search is case-sensitive.", "keywordDescription": "Search keyword in plain HTML or JSON response. The search is case-sensitive.",
"invertKeywordDescription": "Look for the keyword to be absent rather than present.", "invertKeywordDescription": "Look for the keyword to be absent rather than present.",
"jsonQueryDescription": "Do a json Query against the response and check for expected value (Return value will get converted into string for comparison). Check out {0} for the documentation about the query language. A playground can be found {1}.", "jsonQueryDescription": "Use JSON query to parse and extract specific data from the server's JSON response. Compare the evaluated query against the expected value after converting it into a string. Access the response value using $.value and the expected value using $.control. Refer to {0} for detailed documentation on the query language or experiment with queries using the {1}.",
"backupDescription": "You can backup all monitors and notifications into a JSON file.", "backupDescription": "You can backup all monitors and notifications into a JSON file.",
"backupDescription2": "Note: history and event data is not included.", "backupDescription2": "Note: history and event data is not included.",
"backupDescription3": "Sensitive data such as notification tokens are included in the export file; please store export securely.", "backupDescription3": "Sensitive data such as notification tokens are included in the export file; please store export securely.",

View file

@ -171,21 +171,6 @@
</div> </div>
</div> </div>
<!-- Json Query -->
<div v-if="monitor.type === 'json-query'" class="my-3">
<label for="jsonPath" class="form-label">{{ $t("Json Query") }}</label>
<input id="jsonPath" v-model="monitor.jsonPath" type="text" class="form-control" required>
<i18n-t tag="div" class="form-text" keypath="jsonQueryDescription">
<a href="https://jsonata.org/">jsonata.org</a>
<a href="https://try.jsonata.org/">{{ $t('here') }}</a>
</i18n-t>
<br>
<label for="expectedValue" class="form-label">{{ $t("Expected Value") }}</label>
<input id="expectedValue" v-model="monitor.expectedValue" type="text" class="form-control" required>
</div>
<!-- Game --> <!-- Game -->
<!-- GameDig only --> <!-- GameDig only -->
<div v-if="monitor.type === 'gamedig'" class="my-3"> <div v-if="monitor.type === 'gamedig'" class="my-3">
@ -251,7 +236,7 @@
<!-- Hostname --> <!-- Hostname -->
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP only --> <!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping / SNMP only -->
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping' || monitor.type === 'snmp'" class="my-3"> <div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping' || monitor.type === 'snmp'" class="my-3">
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label> <label for="hostname" class="form-label">{{ $t("Hostname or IP Address") }}</label>
<input id="hostname" v-model="monitor.hostname" type="text" class="form-control" :pattern="`${monitor.type === 'mqtt' ? mqttIpOrHostnameRegexPattern : ipOrHostnameRegexPattern}`" required> <input id="hostname" v-model="monitor.hostname" type="text" class="form-control" :pattern="`${monitor.type === 'mqtt' ? mqttIpOrHostnameRegexPattern : ipOrHostnameRegexPattern}`" required>
</div> </div>
@ -278,6 +263,29 @@
</div> </div>
<div v-if="monitor.type === 'snmp'" class="my-3"> <div v-if="monitor.type === 'snmp'" class="my-3">
<label for="snmp_version" class="form-label">{{ $t("SNMP Version") }}</label>
<select id="snmp_version" v-model="monitor.snmpVersion" class="form-select">
<option value="1">
SNMPv1
</option>
<option value="2c">
SNMPv2c
</option>
</select>
</div>
<!-- Json Query -->
<!-- For Json Query / SNMP -->
<div v-if="monitor.type === 'json-query' || monitor.type === 'snmp'" class="my-3">
<div v-if="monitor.jsonPathOperator == 'custom'" 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="$.value" required>
</div>
<div class="d-flex align-items-start"> <div class="d-flex align-items-start">
<div class="me-2"> <div class="me-2">
<label for="json_path_operator" class="form-label">{{ $t("Condition") }}</label> <label for="json_path_operator" class="form-label">{{ $t("Condition") }}</label>
@ -288,6 +296,7 @@
<option value="<=">&lt;=</option> <option value="<=">&lt;=</option>
<option value="==">==</option> <option value="==">==</option>
<option value="contains">contains</option> <option value="contains">contains</option>
<option value="custom">custom</option>
</select> </select>
</div> </div>
<div class="flex-grow-1"> <div class="flex-grow-1">
@ -298,18 +307,6 @@
</div> </div>
</div> </div>
<div v-if="monitor.type === 'snmp'" class="my-3">
<label for="snmp_version" class="form-label">{{ $t("SNMP Version") }}</label>
<select id="snmp_version" v-model="monitor.snmpVersion" class="form-select">
<option value="1">
SNMPv1
</option>
<option value="2c">
SNMPv2c
</option>
</select>
</div>
<!-- DNS Resolver Server --> <!-- DNS Resolver Server -->
<!-- For DNS Type --> <!-- For DNS Type -->
<template v-if="monitor.type === 'dns'"> <template v-if="monitor.type === 'dns'">
@ -1333,6 +1330,13 @@ message HealthCheckResponse {
this.monitor.snmpVersion = "1"; this.monitor.snmpVersion = "1";
} }
// Set default condition for for jsonPathOperator
if (this.monitor.type === "json-query") {
this.monitor.jsonPathOperator = "custom";
} else {
this.monitor.jsonPathOperator = "==";
}
// Get the game list from server // Get the game list from server
if (this.monitor.type === "gamedig") { if (this.monitor.type === "gamedig") {
this.$root.getSocket().emit("getGameList", (res) => { this.$root.getSocket().emit("getGameList", (res) => {

View file

@ -11,8 +11,9 @@
var _a; var _a;
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = exports.CONSOLE_STYLE_FgViolet = exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0; exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = exports.CONSOLE_STYLE_FgViolet = exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0;
exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = void 0; exports.evaluateJsonQuery = exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = void 0;
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const jsonata = require("jsonata");
exports.isDev = process.env.NODE_ENV === "development"; exports.isDev = process.env.NODE_ENV === "development";
exports.isNode = typeof process !== "undefined" && ((_a = process === null || process === void 0 ? void 0 : process.versions) === null || _a === void 0 ? void 0 : _a.node); exports.isNode = typeof process !== "undefined" && ((_a = process === null || process === void 0 ? void 0 : process.versions) === null || _a === void 0 ? void 0 : _a.node);
exports.appName = "Uptime Kuma"; exports.appName = "Uptime Kuma";
@ -396,3 +397,45 @@ function intHash(str, length = 10) {
return (hash % length + length) % length; return (hash % length + length) % length;
} }
exports.intHash = intHash; exports.intHash = intHash;
async function evaluateJsonQuery(data, jsonPath, jsonPathOperator, expectedValue) {
const expected = isNaN(expectedValue) ? expectedValue.toString() : parseFloat(expectedValue);
let response = isNaN(data) ? data.toString() : parseFloat(data);
try {
response = JSON.parse(response);
}
catch (_) {
}
let jsonQueryExpression;
switch (jsonPathOperator) {
case ">":
case ">=":
case "<":
case "<=":
jsonQueryExpression = `$.value ${jsonPathOperator} $.control`;
break;
case "==":
jsonQueryExpression = "$string($.value) = $string($.control)";
break;
case "contains":
jsonQueryExpression = "$contains($string($.value), $string($.control))";
break;
case "custom":
jsonQueryExpression = jsonPath;
break;
default:
throw new Error(`Invalid condition ${jsonPathOperator}`);
}
const expression = jsonata(jsonQueryExpression);
const evaluation = await expression.evaluate({
value: response,
control: expected
});
if (evaluation === undefined) {
throw new Error("Query evaluation returned undefined. Check your query syntax and the structure of the response data.");
}
const result = (jsonPathOperator === "custom")
? evaluation.toString() === expected.toString()
: evaluation;
return result;
}
exports.evaluateJsonQuery = evaluateJsonQuery;

View file

@ -17,6 +17,8 @@ import * as timezone from "dayjs/plugin/timezone";
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
import * as utc from "dayjs/plugin/utc"; import * as utc from "dayjs/plugin/utc";
import * as jsonata from "jsonata";
export const isDev = process.env.NODE_ENV === "development"; export const isDev = process.env.NODE_ENV === "development";
export const isNode = typeof process !== "undefined" && process?.versions?.node; export const isNode = typeof process !== "undefined" && process?.versions?.node;
export const appName = "Uptime Kuma"; export const appName = "Uptime Kuma";
@ -643,3 +645,63 @@ export function intHash(str : string, length = 10) : number {
return (hash % length + length) % length; // Ensure the result is non-negative return (hash % length + length) % length; // Ensure the result is non-negative
} }
/**
* Evaluate a JSON query expression against the provided data.
* @param data The data to evaluate the JSON query against.
* @param jsonPath The JSON path or custom JSON query expression.
* @param jsonPathOperator The operator to use for comparison.
* @param expectedValue The expected value to compare against.
* @returns The result of the evaluation.
* @throws Error if the evaluation returns undefined.
*/
export async function evaluateJsonQuery(data: any, jsonPath: string, jsonPathOperator: string, expectedValue: any): Promise<boolean> {
// Check if inputs are numeric. If not, re-parse as strings. This ensures comparisons are handled correctly.
const expected = isNaN(expectedValue) ? expectedValue.toString() : parseFloat(expectedValue);
let response = isNaN(data) ? data.toString() : parseFloat(data);
try {
// Attempt to parse data as JSON
response = JSON.parse(response);
} catch (_) {
// Failed to parse as JSON, continue with the original value
}
let jsonQueryExpression;
switch (jsonPathOperator) {
case ">":
case ">=":
case "<":
case "<=":
jsonQueryExpression = `$.value ${jsonPathOperator} $.control`;
break;
case "==":
jsonQueryExpression = "$string($.value) = $string($.control)";
break;
case "contains":
jsonQueryExpression = "$contains($string($.value), $string($.control))";
break;
case "custom":
jsonQueryExpression = jsonPath;
break;
default:
throw new Error(`Invalid condition ${jsonPathOperator}`);
}
// Evaluate the JSON Query Expression
const expression = jsonata(jsonQueryExpression);
const evaluation = await expression.evaluate({
value: response,
control: expected
});
if (evaluation === undefined) {
throw new Error("Query evaluation returned undefined. Check your query syntax and the structure of the response data.");
}
const result = (jsonPathOperator === "custom")
? evaluation.toString() === expected.toString()
: evaluation;
return result;
}