Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add SNMP Monitor #4717

Open
wants to merge 48 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 45 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
d92003e
SNMP Initial Commits
mattv8 Apr 27, 2024
a3cdd69
Use net-snmp instead of snmp-native
mattv8 Apr 29, 2024
ff5890a
Updated a comment
mattv8 Apr 29, 2024
4a882be
Further SNMP monitor development
mattv8 Apr 29, 2024
99dc4cf
Wrong variable used
mattv8 Apr 30, 2024
138075a
Update db migration: allow nulls
mattv8 Apr 30, 2024
9c8024c
Update db migration: down function
mattv8 Apr 30, 2024
9d28fcf
Update bean model backend
mattv8 Apr 30, 2024
4593afb
Frontend input validation
mattv8 Apr 30, 2024
9848ce4
Minor frontend styling
mattv8 Apr 30, 2024
704ffd3
Finalized SNMP monitor
mattv8 Apr 30, 2024
b4bd003
Merge branch 'master' into snmp-monitor
CommanderStorm Apr 30, 2024
ba47aca
Apply suggestions from code review
mattv8 Apr 30, 2024
7459654
ES Lint Compliant
mattv8 May 1, 2024
e944492
Corrected down function
mattv8 May 1, 2024
97a9094
ES Lint Compliant
mattv8 May 1, 2024
9ba0f68
Remove supurfluous log.debug
mattv8 May 1, 2024
ba84f01
Delete .EditMonitor.vue.swp
mattv8 May 1, 2024
4699a1c
ES Lint Compliant
mattv8 May 1, 2024
d83c2b9
Revert unintentional changes to EditMonitor.vue
mattv8 May 2, 2024
f059d54
Use frontend timeout
mattv8 May 2, 2024
8e56a81
Refactor how strings/numerics are parsed
mattv8 May 2, 2024
c87ac2f
Move getKey() to util.ts
mattv8 May 3, 2024
09fd816
Updated code comments
mattv8 May 3, 2024
407f729
New dependency for net-snmp
mattv8 May 3, 2024
9053b48
Merge branch 'louislam:master' into snmp-monitor
mattv8 May 3, 2024
4386d0a
Apply suggestions from code review
mattv8 May 5, 2024
0280b2a
A comment about varbinds[0] for clarification
mattv8 May 6, 2024
86b997c
Limit to <= SNMPv2c for now
mattv8 May 6, 2024
0384b34
Remove unnecessary func getKey
mattv8 May 6, 2024
997791b
Default: invalid condition error
mattv8 May 6, 2024
1fe1bb5
Given that above throws, the else case is not nessesary
mattv8 May 6, 2024
433e317
Simplify error catch
mattv8 May 6, 2024
6037912
Consistent placeholder text
mattv8 May 6, 2024
c68b1c6
Remove unnecessary func getKey
mattv8 May 6, 2024
e9b52eb
Separate error cases for SNMP varbind returns
mattv8 May 6, 2024
4ef66b3
SNMP version helptext
mattv8 May 6, 2024
19f21a9
SNMP OID helptext
mattv8 May 6, 2024
56e7fa8
Helptext ALL THE THINGS
mattv8 May 6, 2024
f4842ea
Translation key for OID
mattv8 May 6, 2024
2b5d100
Ensure SNMP session is closed properly
mattv8 May 6, 2024
e5fb726
Missed changes leftover from removal of getKey()
mattv8 May 7, 2024
2015142
Maybe don't helptext all the things...
mattv8 May 7, 2024
8b4b27f
Final cleanup of changes to EditMonitor.vue
mattv8 May 7, 2024
da8f0d1
Apply suggestions from code review
mattv8 May 8, 2024
1c47407
Re-use monitor.radiusPassword for community string
mattv8 May 8, 2024
c475994
Fix ES Lint
mattv8 May 8, 2024
d25ee8f
Using JSON Query Expressions
mattv8 May 10, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 20 additions & 0 deletions db/knex_migrations/2024-04-26-0000-snmp-monitor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
exports.up = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
table.string("snmp_community_string", 255).defaultTo("public");
table.string("snmp_oid").defaultTo(null);
table.enum("snmp_version", [ "1", "2c", "3" ]).defaultTo("2c");
table.float("snmp_control_value").defaultTo(null);
table.string("snmp_condition").defaultTo(null);
});
};

exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.dropColumn("snmp_community_string");
table.dropColumn("snmp_oid");
table.dropColumn("snmp_version");
table.dropColumn("snmp_control_value");
table.dropColumn("snmp_condition");
});
};
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
"mssql": "~8.1.4",
"mysql2": "~3.9.6",
"nanoid": "~3.3.4",
"net-snmp": "^3.11.2",
"node-cloudflared-tunnel": "~1.0.9",
"node-radius-client": "~1.0.0",
"nodemailer": "~6.9.13",
Expand Down
5 changes: 5 additions & 0 deletions server/model/monitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ class Monitor extends BeanModel {
kafkaProducerMessage: this.kafkaProducerMessage,
screenshot,
remote_browser: this.remote_browser,
snmpOid: this.snmpOid,
snmpCondition: this.snmpCondition,
snmpControlValue: this.snmpControlValue,
snmpVersion: this.snmpVersion,
};

if (includeSensitiveData) {
Expand Down Expand Up @@ -190,6 +194,7 @@ class Monitor extends BeanModel {
tlsCert: this.tlsCert,
tlsKey: this.tlsKey,
kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions),
snmpCommunityString: this.snmpCommunityString,
};
}

Expand Down
93 changes: 93 additions & 0 deletions server/monitor-types/snmp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
const { MonitorType } = require("./monitor-type");
const { UP, DOWN, log } = require("../../src/util");
const snmp = require("net-snmp");

class SNMPMonitorType extends MonitorType {
name = "snmp";

/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {

const options = {
port: monitor.port || "161",
retries: monitor.maxretries,
timeout: monitor.timeout * 1000,
version: snmp.Version[monitor.snmpVersion],
};

let session;
try {
session = snmp.createSession(monitor.hostname, monitor.snmpCommunityString, options);

// Handle errors during session creation
session.on("error", (error) => {
throw new Error(`Error creating SNMP session: ${error.message}`);
});

const varbinds = await new Promise((resolve, reject) => {
mattv8 marked this conversation as resolved.
Show resolved Hide resolved
session.get([ monitor.snmpOid ], (error, varbinds) => {
if (error) {
reject(error);
} else {
resolve(varbinds);
}
});
});
log.debug("monitor", `SNMP: Received varbinds (Type: ${snmp.ObjectType[varbinds[0].type]} Value: ${varbinds[0].value}`);

if (varbinds.length === 0) {
throw new Error(`No varbinds returned from SNMP session (OID: ${monitor.snmpOid})`);
mattv8 marked this conversation as resolved.
Show resolved Hide resolved
}

if (varbinds[0].type === snmp.ObjectType.NoSuchInstance) {
throw new Error(`The SNMP query returned that no instance exists for OID ${monitor.snmpOid}`);
}

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

// Check if inputs are numeric. If not, re-parse as strings. This ensures comparisons are handled correctly.
let snmpValue = isNaN(value) ? value.toString() : parseFloat(value);
let snmpControlValue = isNaN(monitor.snmpControlValue) ? monitor.snmpControlValue.toString() : parseFloat(monitor.snmpControlValue);

switch (monitor.snmpCondition) {
case ">":
heartbeat.status = snmpValue > snmpControlValue ? UP : DOWN;
break;
case ">=":
heartbeat.status = snmpValue >= snmpControlValue ? UP : DOWN;
break;
case "<":
heartbeat.status = snmpValue < snmpControlValue ? UP : DOWN;
break;
case "<=":
heartbeat.status = snmpValue <= snmpControlValue ? UP : DOWN;
break;
case "==":
heartbeat.status = snmpValue.toString() === snmpControlValue.toString() ? UP : DOWN;
break;
case "contains":
heartbeat.status = snmpValue.toString().includes(snmpControlValue.toString()) ? UP : DOWN;
break;
default:
throw new Error(`Invalid condition ${monitor.snmpCondition}`);
}
heartbeat.msg = "SNMP value " + (heartbeat.status ? "passes" : "does not pass") + ` comparison: ${value.toString()} ${monitor.snmpCondition} ${snmpControlValue}`;

} catch (err) {
heartbeat.status = DOWN;
heartbeat.msg = `SNMP Error: ${err.message}`;
} finally {
if (session) {
session.close();
}
}
}

}

module.exports = {
SNMPMonitorType,
};
6 changes: 6 additions & 0 deletions server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,12 @@ let needSetup = false;
monitor.kafkaProducerAllowAutoTopicCreation;
bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly;
bean.remote_browser = monitor.remote_browser;
bean.snmpVersion = monitor.snmpVersion;
bean.snmpCommunityString = monitor.snmpCommunityString;
bean.snmpOid = monitor.snmpOid;
bean.snmpCondition = monitor.snmpCondition;
bean.snmpControlValue = monitor.snmpControlValue;
bean.timeout = monitor.timeout;

bean.validate();

Expand Down
2 changes: 2 additions & 0 deletions server/uptime-kuma-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ class UptimeKumaServer {
UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing();
UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType();
UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType();
UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType();

// Allow all CORS origins (polling) in development
let cors = undefined;
Expand Down Expand Up @@ -516,3 +517,4 @@ const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor
const { TailscalePing } = require("./monitor-types/tailscale-ping");
const { DnsMonitorType } = require("./monitor-types/dns");
const { MqttMonitorType } = require("./monitor-types/mqtt");
const { SNMPMonitorType } = require("./monitor-types/snmp");
10 changes: 9 additions & 1 deletion src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -921,5 +921,13 @@
"Allow Long SMS": "Allow Long SMS",
"cellsyntSplitLongMessages": "Split long messages into up to 6 parts. 153 x 6 = 918 characters.",
"max 15 digits": "max 15 digits",
"max 11 alphanumeric characters": "max 11 alphanumeric characters"
"max 11 alphanumeric characters": "max 11 alphanumeric characters",
"Community String": "Community String",
"snmpCommunityStringHelptext": "This string functions as a password to authenticate and control access to SNMP-enabled devices. Match it with your SNMP device's configuration.",
"OID (Object Identifier)": "OID (Object Identifier)",
"snmpOIDHelptext": "Enter the OID for the sensor or status you want to monitor. Use network management tools like MIB browsers or SNMP software if you're unsure about the OID.",
"Condition": "Condition",
"Control Value": "Control Value",
"SNMP Version": "SNMP Version",
"Please enter a valid OID.": "Please enter a valid OID."
}
84 changes: 75 additions & 9 deletions src/pages/EditMonitor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
<option value="ping">
Ping
</option>
<option value="snmp">
SNMP
</option>
<option value="keyword">
HTTP(s) - {{ $t("Keyword") }}
</option>
Expand Down Expand Up @@ -246,19 +249,69 @@
</template>

<!-- Hostname -->
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping 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'" class="my-3">
<!-- 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">
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
<input id="hostname" v-model="monitor.hostname" type="text" class="form-control" :pattern="`${monitor.type === 'mqtt' ? mqttIpOrHostnameRegexPattern : ipOrHostnameRegexPattern}`" required>
</div>

<!-- Port -->
<!-- For TCP Port / Steam / MQTT / Radius Type -->
<div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius'" class="my-3">
<!-- For TCP Port / Steam / MQTT / Radius Type / SNMP -->
<div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'gamedig' || monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'snmp'" class="my-3">
<label for="port" class="form-label">{{ $t("Port") }}</label>
<input id="port" v-model="monitor.port" type="number" class="form-control" required min="0" max="65535" step="1">
</div>

<!-- SNMP Monitor Type -->
<div v-if="monitor.type === 'snmp'" class="my-3">
<label for="snmp_community_string" class="form-label">{{ $t("Community String") }}</label>
mattv8 marked this conversation as resolved.
Show resolved Hide resolved
<!-- TODO: Rename monitor.radiusPassword to monitor.password for general use -->
CommanderStorm marked this conversation as resolved.
Show resolved Hide resolved
<HiddenInput id="snmp_community_string" v-model="monitor.radiusPassword" autocomplete="false" required="true" placeholder="public"></HiddenInput>

<div class="form-text">{{ $t('snmpCommunityStringHelptext')

Check warning on line 271 in src/pages/EditMonitor.vue

View workflow job for this annotation

GitHub Actions / check-linters

Expected 1 line break after opening tag (`<div>`), but no line breaks found
}}</div>

Check failure on line 272 in src/pages/EditMonitor.vue

View workflow job for this annotation

GitHub Actions / check-linters

Expected indentation of 32 spaces but found 1 space

Check warning on line 272 in src/pages/EditMonitor.vue

View workflow job for this annotation

GitHub Actions / check-linters

Expected 1 line break before closing tag (`</div>`), but no line breaks found
</div>

<div v-if="monitor.type === 'snmp'" class="my-3">
<label for="snmp_oid" class="form-label">{{ $t("OID (Object Identifier)") }}</label>
mattv8 marked this conversation as resolved.
Show resolved Hide resolved
mattv8 marked this conversation as resolved.
Show resolved Hide resolved
<input id="snmp_oid" v-model="monitor.snmpOid" :title="$t('Please enter a valid OID.') + ' ' + $t('Example:', ['1.3.6.1.4.1.9.6.1.101'])" type="text" class="form-control" pattern="^([0-2])((\.0)|(\.[1-9][0-9]*))*$" placeholder="1.3.6.1.4.1.9.6.1.101" required>
<div class="form-text">{{

Check warning on line 278 in src/pages/EditMonitor.vue

View workflow job for this annotation

GitHub Actions / check-linters

Expected 1 line break after opening tag (`<div>`), but no line breaks found

Check failure on line 278 in src/pages/EditMonitor.vue

View workflow job for this annotation

GitHub Actions / check-linters

Trailing spaces not allowed
$t('snmpOIDHelptext') }}</div>

Check failure on line 279 in src/pages/EditMonitor.vue

View workflow job for this annotation

GitHub Actions / check-linters

Expected indentation of 36 spaces but found 0 spaces

Check warning on line 279 in src/pages/EditMonitor.vue

View workflow job for this annotation

GitHub Actions / check-linters

Expected 1 line break before closing tag (`</div>`), but no line breaks found
</div>

<div v-if="monitor.type === 'snmp'" class="my-3">
<div class="d-flex align-items-start">
<div class="me-2">
<label for="snmp_condition" class="form-label">{{ $t("Condition") }}</label>
<select id="snmp_condition" v-model="monitor.snmpCondition" class="form-select me-3" required>
<option value=">">&gt;</option>
<option value=">=">&gt;=</option>
<option value="<">&lt;</option>
<option value="<=">&lt;=</option>
<option value="==">==</option>
<option value="contains">contains</option>
</select>
</div>
<div class="flex-grow-1">
<label for="snmp_control_value" class="form-label">{{ $t("Control Value") }}</label>
<input v-if="monitor.snmpCondition !== 'contains' && monitor.snmpCondition !== '=='" id="snmp_control_value" v-model="monitor.snmpControlValue" type="number" class="form-control" required step=".01">
<input v-else id="snmp_control_value" v-model="monitor.snmpControlValue" type="text" class="form-control" required>
</div>
</div>
</div>

<div v-if="monitor.type === 'snmp'" class="my-3">
<label for="snmp_version" class="form-label">{{ $t("SNMP Version") }}</label>
mattv8 marked this conversation as resolved.
Show resolved Hide resolved
<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 -->
<!-- For DNS Type -->
<template v-if="monitor.type === 'dns'">
Expand Down Expand Up @@ -456,8 +509,8 @@
<input id="retry-interval" v-model="monitor.retryInterval" type="number" class="form-control" required :min="minInterval" step="1">
</div>

<!-- Timeout: HTTP / Keyword only -->
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query'" class="my-3">
<!-- Timeout: HTTP / Keyword / SNMP only -->
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'snmp'" class="my-3">
<label for="timeout" class="form-label">{{ $t("Request Timeout") }} ({{ $t("timeoutAfter", [ monitor.timeout || clampTimeout(monitor.interval) ]) }})</label>
<input id="timeout" v-model="monitor.timeout" type="number" class="form-control" required min="0" step="0.1">
</div>
Expand Down Expand Up @@ -919,7 +972,6 @@
retryInterval: 60,
resendInterval: 0,
maxretries: 0,
timeout: 48,
notificationIDList: {},
ignoreTls: false,
upsideDown: false,
Expand Down Expand Up @@ -1131,8 +1183,8 @@
// Only groups, not itself, not a decendant
result = result.filter(
monitor => monitor.type === "group" &&
monitor.id !== this.monitor.id &&
!this.monitor.childrenIDs?.includes(monitor.id)
monitor.id !== this.monitor.id &&
!this.monitor.childrenIDs?.includes(monitor.id)
);

// Filter result by active state, weight and alphabetical
Expand Down Expand Up @@ -1264,11 +1316,25 @@
this.monitor.port = "53";
} else if (this.monitor.type === "radius") {
this.monitor.port = "1812";
} else if (this.monitor.type === "snmp") {
this.monitor.port = "161";
} else {
this.monitor.port = undefined;
}
}

if (this.monitor.type === "snmp") {
// snmp is not expected to be executed via the internet => we can choose a lower default timeout
this.monitor.timeout = 5;
} else {
this.monitor.timeout = 48;
}

// Set default SNMP version
if (!this.monitor.snmpVersion) {
this.monitor.snmpVersion = "1";
}

// Get the game list from server
if (this.monitor.type === "gamedig") {
this.$root.getSocket().emit("getGameList", (res) => {
Expand Down