Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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");
});
};
1,274 changes: 703 additions & 571 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@
"stylelint-config-standard": "~25.0.0",
"terser": "~5.15.0",
"test": "~3.3.0",
"testcontainers": "^10.13.1",
"testcontainers": "^11.5.0",
"typescript": "~4.4.4",
"v-pagination-3": "~0.1.7",
"vite": "~5.4.15",
Expand Down
17 changes: 16 additions & 1 deletion server/monitor-types/snmp.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,22 @@ 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 @@ -1109,6 +1109,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
15 changes: 15 additions & 0 deletions src/pages/EditMonitor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -511,8 +511,23 @@
<select id="snmp_version" v-model="monitor.snmpVersion" class="form-select">
<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
128 changes: 128 additions & 0 deletions test/backend-test/test-snmp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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 () => {
const container = await new GenericContainer("polinux/snmpd").withExposedPorts("161/udp").start();

try {
// Get the mapped UDP port
const hostPort = container.getMappedPort("161/udp");
const hostIp = container.getHost();

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

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: "!=",
expectedValue: "",
};

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