Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions db/knex_migrations/2025-12-31-2143-add-snmp-v3-username.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
exports.up = async function (knex) {
await knex.schema.alterTable("monitor", (table) => {
table.string("snmp_v3_username", 255);
});
};

exports.down = async function (knex) {
await knex.schema.alterTable("monitor", (table) => {
table.dropColumn("snmp_v3_username");
});
};
21 changes: 20 additions & 1 deletion server/monitor-types/snmp.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,26 @@ class SNMPMonitorType extends MonitorType {
timeout: monitor.timeout * 1000,
version: snmp.Version[monitor.snmpVersion],
};
session = snmp.createSession(monitor.hostname, monitor.radiusPassword, sessionOptions);

if (monitor.snmpVersion === "3") {
if (!monitor.snmp_v3_username) {
throw new Error("SNMPv3 username is required");
}
// SNMPv3 currently defaults to noAuthNoPriv.
// Supporting authNoPriv / authPriv requires additional inputs
// (auth/priv protocols, passwords), validation, secure storage,
// and database migrations, which is intentionally left for
// a follow-up PR to keep this change scoped.
sessionOptions.securityLevel = snmp.SecurityLevel.noAuthNoPriv;
sessionOptions.username = monitor.snmp_v3_username;
session = snmp.createV3Session(
monitor.hostname,
monitor.snmp_v3_username,
sessionOptions
);
} else {
session = snmp.createSession(monitor.hostname, monitor.radiusPassword, sessionOptions);
}

// Handle errors during session creation
session.on("error", (error) => {
Expand Down
1 change: 1 addition & 0 deletions src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,7 @@
"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.",
"snmpV3Username": "SNMPv3 Username",
"Condition": "Condition",
"SNMP Version": "SNMP Version",
"Please enter a valid OID.": "Please enter a valid OID.",
Expand Down
25 changes: 23 additions & 2 deletions src/pages/EditMonitor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -509,10 +509,31 @@
<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>
<option value="1">
SNMPv1
</option>
<option value="2c">
SNMPv2c
</option>
<option value="3">
SNMPv3
</option>
</select>
</div>
<div v-if="monitor.type === 'snmp' && monitor.snmpVersion === '3'" class="my-3">
<label for="snmp_v3_username" class="form-label">
{{ $t('snmpV3Username') }}
</label>

<input
id="snmp_v3_username"
v-model="monitor.snmpV3Username"
type="text"
class="form-control"
placeholder="SNMPv3 username"
required
>
</div>

<div v-if="monitor.type === 'smtp'" class="my-3">
<label for="smtp_security" class="form-label">{{ $t("SMTP Security") }}</label>
Expand Down
144 changes: 144 additions & 0 deletions test/backend-test/test-snmp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
const { describe, test } = require("node:test");
const assert = require("node:assert/strict");
const { GenericContainer } = require("testcontainers");
const { SNMPMonitorType } = require("../../server/monitor-types/snmp");
const { UP } = require("../../src/util");
const snmp = require("net-snmp");

describe("SNMPMonitorType", () => {
test(
"check() sets heartbeat to UP when SNMP agent responds",
{
skip:
!!process.env.CI &&
(process.platform !== "linux" || process.arch !== "x64"),
},
async () => {
// Expose SNMP port via Testcontainers
const container = await new GenericContainer("polinux/snmpd")
.withExposedPorts("161/udp")
.start();

try {
// Dynamically retrieve the assigned host port and IP
const hostPort = container.getMappedPort(161);
const hostIp = container.getHost();

// UDP service small wait to ensure snmpd is ready inside container
await new Promise((r) => setTimeout(r, 1500));

const monitor = {
type: "snmp",
hostname: hostIp,
port: hostPort,
snmpVersion: "2c",
radiusPassword: "public",
snmpOid: "1.3.6.1.2.1.1.1.0",
timeout: 5,
maxretries: 1,
jsonPath: "$",
jsonPathOperator: "exists",
expectedValue: null,
};

const snmpMonitor = new SNMPMonitorType();
const heartbeat = {};

await snmpMonitor.check(monitor, heartbeat);

assert.strictEqual(heartbeat.status, UP);
assert.match(heartbeat.msg, /JSON query passes/);
} finally {
await container.stop();
}
}
);

test(
"check() throws when SNMP agent does not respond",
{
skip:
!!process.env.CI &&
(process.platform !== "linux" || process.arch !== "x64"),
},
async () => {
const monitor = {
type: "snmp",
hostname: "127.0.0.1",
port: 65530, // Assuming no SNMP agent is running here
snmpVersion: "2c",
radiusPassword: "public",
snmpOid: "1.3.6.1.2.1.1.1.0",
timeout: 1,
maxretries: 1,
};

const snmpMonitor = new SNMPMonitorType();
const heartbeat = {};

await assert.rejects(
() => snmpMonitor.check(monitor, heartbeat),
/timeout|RequestTimedOutError/i
);
}
);

test("check() uses SNMPv3 noAuthNoPriv session when version is 3", async () => {
const originalCreateV3Session = snmp.createV3Session;
const originalCreateSession = snmp.createSession;

let createV3Called = false;
let createSessionCalled = false;
let receivedOptions = null;

// Stub createV3Session
snmp.createV3Session = function (_host, _username, options) {
createV3Called = true;
receivedOptions = options;

return {
on: () => {},
close: () => {},
// Stop execution after session creation to avoid real network I/O.
get: (_oids, cb) => cb(new Error("stop test here")),
};
};

// Stub createSession
snmp.createSession = function () {
createSessionCalled = true;
return {};
};

const monitor = {
type: "snmp",
hostname: "127.0.0.1",
port: 161,
timeout: 5,
maxretries: 1,
snmpVersion: "3",
snmp_v3_username: "testuser",
snmpOid: "1.3.6.1.2.1.1.1.0",
};

const snmpMonitor = new SNMPMonitorType();
const heartbeat = {};

await assert.rejects(
() => snmpMonitor.check(monitor, heartbeat),
/stop test here/
);

// Assertions
assert.strictEqual(createV3Called, true);
assert.strictEqual(createSessionCalled, false);
assert.strictEqual(
receivedOptions.securityLevel,
snmp.SecurityLevel.noAuthNoPriv
);

// Restore originals
snmp.createV3Session = originalCreateV3Session;
snmp.createSession = originalCreateSession;
});
});
Loading