Loading CMakeLists.txt +6 −1 Changes for CMakeLists.txt: 6 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -10,7 +10,8 @@ project(webctl VERSION 0.2.3 LANGUAGES CXX) # forwarded to the backend; drops to the webctl user # after binding # modules/ one shared object per area (users, network, shares, # raid, lvm, filesystems, services, updates, system), # raid, lvm, filesystems, services, updates, snmp, # system), # dlopen()ed by the backend from its module directory # www/ the AJAX UI; www/modules/<name>.js per module Loading Loading @@ -103,6 +104,10 @@ target_link_libraries(webctl_mod_disks PRIVATE sysbus::sysbus) # format; modules/common/blockdev.cpp: free devices, guarded probing webctl_module(filesystems modules/filesystems/filesystems.cpp modules/common/blockdev.cpp) target_link_libraries(webctl_mod_filesystems PRIVATE sysbus::sysbus) # SNMP notifications: settings and status of snmpdd over sysbus; needs # snmpdd/api.h (snmpdd-dev) webctl_module(snmp modules/snmp/snmp.cpp) target_link_libraries(webctl_mod_snmp PRIVATE sysbus::sysbus) install(TARGETS webctl-backend webctl-frontend RUNTIME DESTINATION ${CMAKE_INSTALL_SBINDIR}) install(DIRECTORY www/ DESTINATION ${WEBCTL_WWWDIR}) Loading modules/snmp/snmp.cpp 0 → 100644 +213 −0 Changes for modules/snmp/snmp.cpp: 213 added lines, 0 removed lines. Original line number Diff line number Diff line /******************************************************************************* Copyright (c) 2026, Jan Koester jan.koester@gmx.net All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ /* webctl module "snmp": the settings and status of snmpdd, entirely * over its sysbus API (include/snmpdd/api.h), plus syslite's services to * choose from. * * info -> {settings, status, units} * settings: enabled, interval, events, report{raid, lvm, * filesystems, disks, smart_attributes, services}, fsWarning, * fsCritical, tempWarning, tempCritical, * services[], targets[{host, port, version, community}], * enterprise (read only) * status: lastReport, nextReport (unix time, 0: never/off), sent, * status, problems[], targets[{target, sent, error}] * save {settings} replaces them all (as above, without enterprise) * send a report now -> {sent} */ #include <cstdlib> #include <string> #include <vector> #include <snmpdd/api.h> #include <sysbus/client.h> #include <webctl/module.h> using webctl::error; using webctl::jstr; using webctl::tr; namespace { using Fields = std::vector<std::pair<std::string, std::string>>; const char *yesNo(bool b) { return b ? "yes" : "no"; } long long num(const std::string &s) { return std::strtoll(s.c_str(), nullptr, 10); } // an snmpdd call; on failure the error object to return json_object *callSnmpdd(sysbus::Client &bus, const char *method, const Fields &args, sysbus::Message &reply) { Fields all = args; all.emplace_back("lang", webctl::language()); reply = bus.callService(snmpdd::Service, method, all); if (reply.type != sysbus::MessageType::Error) return nullptr; const std::string &name = reply.member; if (name == sysbus::error::ServiceUnknown) return error(tr("snmpdd isn't running (Services: snmpdd)", "snmpdd läuft nicht (Dienste: snmpdd)"), 503); int status = name == sysbus::error::AccessDenied ? 403 : name == sysbus::error::InvalidArgs ? 400 : 500; return error(reply.get("message", name), status); } json_object *settingsJson(const sysbus::Message &r) { json_object *o = json_object_new_object(); json_object *report = json_object_new_object(); json_object *services = json_object_new_array(), *targets = json_object_new_array(); json_object *target = nullptr; for (auto &f : r.fields) { const std::string &k = f.first, &v = f.second; if (k == "enabled" || k == "events") json_object_object_add(o, k.c_str(), json_object_new_boolean(v == "yes")); else if (k == "interval") json_object_object_add(o, "interval", json_object_new_int64(num(v))); else if (k == "fs_warning") json_object_object_add(o, "fsWarning", json_object_new_int64(num(v))); else if (k == "fs_critical") json_object_object_add(o, "fsCritical", json_object_new_int64(num(v))); else if (k == "temp_warning") json_object_object_add(o, "tempWarning", json_object_new_int64(num(v))); else if (k == "temp_critical") json_object_object_add(o, "tempCritical", json_object_new_int64(num(v))); else if (k.compare(0, 7, "report_") == 0) json_object_object_add(report, k.c_str() + 7, json_object_new_boolean(v == "yes")); else if (k == "enterprise") json_object_object_add(o, "enterprise", jstr(v)); else if (k == "service") json_object_array_add(services, jstr(v)); else if (k == "target") { target = json_object_new_object(); json_object_array_add(targets, target); json_object_object_add(target, "host", jstr(v)); } else if (target && k == "port") json_object_object_add(target, "port", json_object_new_int64(num(v))); else if (target && (k == "version" || k == "community")) json_object_object_add(target, k.c_str(), jstr(v)); } json_object_object_add(o, "report", report); json_object_object_add(o, "services", services); json_object_object_add(o, "targets", targets); return o; } json_object *statusJson(const sysbus::Message &r) { json_object *o = json_object_new_object(); json_object *problems = json_object_new_array(), *targets = json_object_new_array(); json_object *target = nullptr; for (auto &f : r.fields) { const std::string &k = f.first, &v = f.second; if (k == "target") { target = json_object_new_object(); json_object_array_add(targets, target); json_object_object_add(target, "target", jstr(v)); } else if (target && k == "sent") json_object_object_add(target, "sent", json_object_new_int64(num(v))); else if (target && k == "error") json_object_object_add(target, "error", jstr(v)); else if (k == "last_report") json_object_object_add(o, "lastReport", json_object_new_int64(num(v))); else if (k == "next_report") json_object_object_add(o, "nextReport", json_object_new_int64(num(v))); else if (k == "sent") json_object_object_add(o, "sent", json_object_new_int64(num(v))); else if (k == "status") json_object_object_add(o, "status", jstr(v)); else if (k == "problem") json_object_array_add(problems, jstr(v)); } json_object_object_add(o, "problems", problems); json_object_object_add(o, "targets", targets); return o; } // the settings from the UI as snmpdd's fields; "" or why not std::string settingsFields(json_object *s, Fields &out) { if (!s || !json_object_is_type(s, json_type_object)) return tr("no settings given", "keine Einstellungen angegeben"); out.emplace_back("enabled", yesNo(webctl::boolean(s, "enabled"))); out.emplace_back("interval", std::to_string(webctl::integer(s, "interval", 300))); out.emplace_back("events", yesNo(webctl::boolean(s, "events", true))); json_object *report = nullptr; json_object_object_get_ex(s, "report", &report); for (const char *k : {"raid", "lvm", "filesystems", "disks", "smart_attributes", "services"}) out.emplace_back(std::string("report_") + k, yesNo(report && json_object_is_type(report, json_type_object) ? webctl::boolean(report, k, true) : true)); out.emplace_back("fs_warning", std::to_string(webctl::integer(s, "fsWarning", 85))); out.emplace_back("fs_critical", std::to_string(webctl::integer(s, "fsCritical", 95))); out.emplace_back("temp_warning", std::to_string(webctl::integer(s, "tempWarning", 50))); out.emplace_back("temp_critical", std::to_string(webctl::integer(s, "tempCritical", 60))); json_object *list = nullptr; if (json_object_object_get_ex(s, "services", &list) && list) { if (!json_object_is_type(list, json_type_array)) return tr("services: a list", "services: eine Liste"); for (size_t i = 0; i < json_object_array_length(list); ++i) { const char *name = json_object_get_string(json_object_array_get_idx(list, i)); if (name && *name) out.emplace_back("service", name); } } if (json_object_object_get_ex(s, "targets", &list) && list) { if (!json_object_is_type(list, json_type_array)) return tr("targets: a list", "targets: eine Liste"); for (size_t i = 0; i < json_object_array_length(list); ++i) { json_object *t = json_object_array_get_idx(list, i); if (!t || !json_object_is_type(t, json_type_object)) return tr("invalid target", "ungültiges Ziel"); out.emplace_back("target", webctl::str(t, "host")); out.emplace_back("port", std::to_string(webctl::integer(t, "port", 162))); out.emplace_back("version", webctl::str(t, "version", "2c")); out.emplace_back("community", webctl::str(t, "community")); } } return ""; } json_object *call(const webctl_request *req) { webctl::LanguageScope lang(req->lang); std::string action = req->action; sysbus::Client bus; if (!bus.connect()) return error(tr("sysbus isn't reachable: %1", "sysbus ist nicht erreichbar: %1", {bus.lastError()}), 503); sysbus::Message reply; if (action == "info") { if (json_object *err = callSnmpdd(bus, snmpdd::method::GetSettings, {}, reply)) return err; json_object *settings = settingsJson(reply); if (json_object *err = callSnmpdd(bus, snmpdd::method::GetStatus, {}, reply)) { json_object_put(settings); return err; } json_object *out = json_object_new_object(); json_object_object_add(out, "settings", settings); json_object_object_add(out, "status", statusJson(reply)); // the services to choose from (mounts are file systems) json_object *units = json_object_new_array(); for (auto &u : bus.listUnits()) if (u.kind != "mount") json_object_array_add(units, jstr(u.name)); json_object_object_add(out, "units", units); return out; } if (action == "save") { json_object *s = nullptr; json_object_object_get_ex(req->args, "settings", &s); Fields f; std::string bad = settingsFields(s, f); if (!bad.empty()) return error(bad); if (json_object *err = callSnmpdd(bus, snmpdd::method::SetSettings, f, reply)) return err; return webctl::ok(); } if (action == "send") { if (json_object *err = callSnmpdd(bus, snmpdd::method::SendReport, {}, reply)) return err; json_object *out = json_object_new_object(); json_object_object_add(out, "sent", json_object_new_int64(num(reply.get("sent", "0")))); return out; } return error(tr("unknown action %1", "unbekannte Aktion %1", {action}), 404); } } // namespace WEBCTL_MODULE({WEBCTL_MODULE_ABI, "snmp", "SNMP", 38, nullptr, nullptr, call}); www/modules/snmp.js 0 → 100644 +206 −0 Changes for www/modules/snmp.js: 206 added lines, 0 removed lines. Original line number Diff line number Diff line 'use strict'; // SNMP notifications (backend module "snmp", snmpdd over sysbus) (() => { const {h, t, table, dialog, confirmAction, call, run, toast} = webctl; webctl.translations({de: { 'SNMP notifications': 'SNMP-Benachrichtigungen', 'Send report now': 'Bericht jetzt senden', 'Settings': 'Einstellungen', 'Reports': 'Berichte', 'on': 'an', 'off': 'aus', 'every {0}': 'alle {0}', 'Last report': 'Letzter Bericht', 'Next report': 'Nächster Bericht', 'never': 'nie', 'Status': 'Status', 'Notifications sent': 'Gesendete Benachrichtigungen', 'Not collected': 'Nicht erfasst', 'Enterprise OID': 'Enterprise-OID', 'ok': 'ok', 'warning': 'Warnung', 'critical': 'kritisch', 'unknown': 'unbekannt', 'Targets (NMS)': 'Ziele (NMS)', 'Add target': 'Ziel hinzufügen', 'Host': 'Host', 'Port': 'Port', 'Version': 'Version', 'Community': 'Community', 'Sent': 'Gesendet', 'Error': 'Fehler', 'Edit': 'Bearbeiten', 'Remove': 'Entfernen', 'No targets yet.': 'Noch keine Ziele.', 'Target {0}': 'Ziel {0}', 'New target': 'Neues Ziel', 'Add': 'Hinzufügen', 'Host name or IP address of the network management station.': 'Hostname oder IP-Adresse der Network-Management-Station.', 'Remove target {0}?': 'Ziel {0} entfernen?', 'It gets no more notifications.': 'Es bekommt keine Benachrichtigungen mehr.', 'SNMP settings': 'SNMP-Einstellungen', 'Send reports': 'Berichte senden', 'Interval (seconds)': 'Intervall (Sekunden)', 'Report': 'Berichten', 'RAID arrays': 'RAID-Verbünde', 'LVM volume groups': 'LVM-Volume-Groups', 'File systems': 'Dateisysteme', 'Disks (SMART)': 'Festplatten (SMART)', 'Services': 'Dienste', 'SMART attributes of ATA drives': 'SMART-Attribute der ATA-Laufwerke', 'Disk temperature warning at (°C)': 'Festplatten-Temperatur Warnung ab (°C)', 'critical at (°C)': 'kritisch ab (°C)', 'Drives in standby aren\'t woken up: their last reading is sent.': 'Laufwerke im Standby werden nicht geweckt: ihre letzte Messung wird gesendet.', 'Events at once (SMART warning, self-test failed, service failed, status changed)': 'Ereignisse sofort (SMART-Warnung, Selbsttest fehlgeschlagen, Dienst ausgefallen, Statuswechsel)', 'File system warning at (% used)': 'Dateisystem-Warnung ab (% belegt)', 'critical at (% used)': 'kritisch ab (% belegt)', 'Services reported': 'Berichtete Dienste', 'None ticked: all services.': 'Keiner angehakt: alle Dienste.', 'Settings saved.': 'Einstellungen gespeichert.', '{0} notifications sent.': '{0} Benachrichtigungen gesendet.', 'Target saved.': 'Ziel gespeichert.', 'Target removed.': 'Ziel entfernt.', 'SNMPDD-MIB describes the notifications (/usr/share/snmp/mibs/SNMPDD-MIB.txt); load it into the NMS.': 'SNMPDD-MIB beschreibt die Benachrichtigungen (/usr/share/snmp/mibs/SNMPDD-MIB.txt); im NMS laden.', '{0} s': '{0} s', '{0} min': '{0} min', '{0} h': '{0} h', }}); let root; let data; const stateClass = {ok: 'ok', warning: 'warn', critical: 'danger', unknown: ''}; const when = ts => ts ? new Date(ts * 1000).toLocaleString() : t('never'); const every = s => s % 3600 === 0 ? t('{0} h', s / 3600) : s % 60 === 0 ? t('{0} min', s / 60) : t('{0} s', s); async function load() { const r = await run(() => call('snmp', 'info')); if (!r) return; data = r; draw(); } // the settings as they are, with changes async function save(changes, success) { const s = {...data.settings, ...changes}; delete s.enterprise; await call('snmp', 'save', {settings: s}); toast(success); load(); } function settingsDialog() { const s = data.settings; const units = [...data.units]; for (const u of s.services) if (!units.includes(u)) units.push(u); units.sort(); const report = (key, label) => h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'report_' + key, checked: s.report[key] !== false}), label); dialog({ title: t('SNMP settings'), fields: () => [ h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'enabled', checked: s.enabled}), h('strong', {}, t('Send reports'))), h('label', {}, t('Interval (seconds)'), h('input', {name: 'interval', type: 'number', min: 30, max: 86400, value: s.interval, required: true})), h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'events', checked: s.events}), t('Events at once (SMART warning, self-test failed, service failed, status changed)')), h('label', {}, t('Report')), report('raid', t('RAID arrays')), report('lvm', t('LVM volume groups')), report('filesystems', t('File systems')), report('disks', t('Disks (SMART)')), report('smart_attributes', t('SMART attributes of ATA drives')), report('services', t('Services')), h('p', {class: 'hint'}, t('Drives in standby aren\'t woken up: their last reading is sent.')), h('div', {class: 'row'}, h('label', {}, t('File system warning at (% used)'), h('input', {name: 'fsWarning', type: 'number', min: 1, max: 100, value: s.fsWarning, required: true})), h('label', {}, t('critical at (% used)'), h('input', {name: 'fsCritical', type: 'number', min: 1, max: 100, value: s.fsCritical, required: true}))), h('div', {class: 'row'}, h('label', {}, t('Disk temperature warning at (°C)'), h('input', {name: 'tempWarning', type: 'number', min: 20, max: 100, value: s.tempWarning, required: true})), h('label', {}, t('critical at (°C)'), h('input', {name: 'tempCritical', type: 'number', min: 20, max: 100, value: s.tempCritical, required: true}))), h('label', {}, t('Services reported'), h('div', {class: 'checklist'}, units.map(u => h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'services', value: u, checked: s.services.includes(u)}), u)))), h('p', {class: 'hint'}, t('None ticked: all services.')), ], onSubmit: d => save({ enabled: d.enabled, interval: Number(d.interval), events: d.events, report: {raid: d.report_raid, lvm: d.report_lvm, filesystems: d.report_filesystems, disks: d.report_disks, smart_attributes: d.report_smart_attributes, services: d.report_services}, fsWarning: Number(d.fsWarning), fsCritical: Number(d.fsCritical), tempWarning: Number(d.tempWarning), tempCritical: Number(d.tempCritical), services: d.services || [], }, t('Settings saved.')), }); } function targetDialog(index) { const old = index >= 0 ? data.settings.targets[index] : null; const tg = old || {host: '', port: 162, version: '2c', community: 'public'}; dialog({ title: old ? t('Target {0}', old.host) : t('New target'), submit: old ? undefined : t('Add'), fields: () => [ h('div', {class: 'row'}, h('label', {}, t('Host'), h('input', {name: 'host', value: tg.host, required: true})), h('label', {}, t('Port'), h('input', {name: 'port', type: 'number', min: 1, max: 65535, value: tg.port, required: true}))), h('p', {class: 'hint'}, t('Host name or IP address of the network management station.')), h('div', {class: 'row'}, h('label', {}, t('Version'), h('select', {name: 'version'}, ['2c', '1'].map(v => h('option', {value: v, selected: v === tg.version}, 'SNMPv' + v)))), h('label', {}, t('Community'), h('input', {name: 'community', value: tg.community, required: true, maxlength: 255}))), ], onSubmit: d => { const targets = [...data.settings.targets]; const n = {host: d.host.trim(), port: Number(d.port), version: d.version, community: d.community}; if (old) targets[index] = n; else targets.push(n); return save({targets}, t('Target saved.')); }, }); } async function removeTarget(index) { const tg = data.settings.targets[index]; if (!await confirmAction(t('Remove target {0}?', tg.host), t('It gets no more notifications.'), t('Remove'))) return; const targets = data.settings.targets.filter((_, i) => i !== index); // reports need a target: removing the last one turns them off await run(() => save({targets, enabled: data.settings.enabled && targets.length > 0}, t('Target removed.'))); } async function sendNow(button) { button.disabled = true; const r = await run(() => call('snmp', 'send')); button.disabled = false; if (r) toast(t('{0} notifications sent.', r.sent)); load(); } function draw() { const s = data.settings, st = data.status; const row = (label, value) => h('tr', {}, h('th', {}, label), h('td', {}, value)); const badge = name => h('span', {class: 'badge ' + (stateClass[name] || '')}, t(name || 'unknown')); const key = tg => (tg.host.includes(':') ? '[' + tg.host + ']' : tg.host) + ':' + tg.port; const sendButton = h('button', {onclick: () => sendNow(sendButton), disabled: !s.targets.length}, t('Send report now')); root.replaceChildren( h('section', {class: 'panel'}, h('div', {class: 'toolbar'}, h('h2', {}, t('SNMP notifications')), h('button', {onclick: load}, t('Refresh')), sendButton, h('button', {class: 'primary', onclick: settingsDialog}, t('Settings'))), h('div', {class: 'table-wrap'}, h('table', {class: 'facts'}, h('tbody', {}, row(t('Reports'), s.enabled ? [h('span', {class: 'badge ok'}, t('on')), ' ', t('every {0}', every(s.interval))] : h('span', {class: 'badge'}, t('off'))), row(t('Last report'), when(st.lastReport)), row(t('Next report'), s.enabled ? when(st.nextReport) : '–'), row(t('Status'), st.lastReport ? badge(st.status) : '–'), st.problems.length ? row(t('Not collected'), st.problems.join('; ')) : null, row(t('Notifications sent'), String(st.sent || 0)), row(t('Enterprise OID'), h('span', {class: 'mono'}, s.enterprise || '')))))), h('section', {class: 'panel'}, h('div', {class: 'toolbar'}, h('h2', {}, t('Targets (NMS)')), h('button', {onclick: () => targetDialog(-1)}, t('Add target'))), table([ {title: t('Host'), render: tg => h('strong', {}, tg.host)}, {title: t('Port'), render: tg => tg.port, class: 'mono'}, {title: t('Version'), render: tg => 'SNMPv' + tg.version}, {title: t('Community'), render: tg => h('span', {class: 'mono'}, tg.community)}, {title: t('Sent'), render: tg => String((st.targets.find(x => x.target === key(tg)) || {}).sent || 0)}, {title: t('Error'), render: tg => { const e = (st.targets.find(x => x.target === key(tg)) || {}).error; return e ? h('span', {class: 'badge danger'}, e) : ''; }}, {title: '', class: 'actions', render: tg => { const i = s.targets.indexOf(tg); return [h('button', {class: 'small', onclick: () => targetDialog(i)}, t('Edit')), h('button', {class: 'small danger', onclick: () => removeTarget(i)}, t('Remove'))]; }}, ], s.targets, t('No targets yet.')), h('p', {class: 'hint'}, t('SNMPDD-MIB describes the notifications (/usr/share/snmp/mibs/SNMPDD-MIB.txt); load it into the NMS.')))); } webctl.register('snmp', { render(container) { root = container; root.append(h('p', {class: 'empty'}, t('Loading …'))); load(); const timer = setInterval(() => { if (!document.querySelector('dialog')) load(); }, 15000); return () => clearInterval(timer); }, }); })(); Loading
CMakeLists.txt +6 −1 Changes for CMakeLists.txt: 6 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -10,7 +10,8 @@ project(webctl VERSION 0.2.3 LANGUAGES CXX) # forwarded to the backend; drops to the webctl user # after binding # modules/ one shared object per area (users, network, shares, # raid, lvm, filesystems, services, updates, system), # raid, lvm, filesystems, services, updates, snmp, # system), # dlopen()ed by the backend from its module directory # www/ the AJAX UI; www/modules/<name>.js per module Loading Loading @@ -103,6 +104,10 @@ target_link_libraries(webctl_mod_disks PRIVATE sysbus::sysbus) # format; modules/common/blockdev.cpp: free devices, guarded probing webctl_module(filesystems modules/filesystems/filesystems.cpp modules/common/blockdev.cpp) target_link_libraries(webctl_mod_filesystems PRIVATE sysbus::sysbus) # SNMP notifications: settings and status of snmpdd over sysbus; needs # snmpdd/api.h (snmpdd-dev) webctl_module(snmp modules/snmp/snmp.cpp) target_link_libraries(webctl_mod_snmp PRIVATE sysbus::sysbus) install(TARGETS webctl-backend webctl-frontend RUNTIME DESTINATION ${CMAKE_INSTALL_SBINDIR}) install(DIRECTORY www/ DESTINATION ${WEBCTL_WWWDIR}) Loading
modules/snmp/snmp.cpp 0 → 100644 +213 −0 Changes for modules/snmp/snmp.cpp: 213 added lines, 0 removed lines. Original line number Diff line number Diff line /******************************************************************************* Copyright (c) 2026, Jan Koester jan.koester@gmx.net All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ /* webctl module "snmp": the settings and status of snmpdd, entirely * over its sysbus API (include/snmpdd/api.h), plus syslite's services to * choose from. * * info -> {settings, status, units} * settings: enabled, interval, events, report{raid, lvm, * filesystems, disks, smart_attributes, services}, fsWarning, * fsCritical, tempWarning, tempCritical, * services[], targets[{host, port, version, community}], * enterprise (read only) * status: lastReport, nextReport (unix time, 0: never/off), sent, * status, problems[], targets[{target, sent, error}] * save {settings} replaces them all (as above, without enterprise) * send a report now -> {sent} */ #include <cstdlib> #include <string> #include <vector> #include <snmpdd/api.h> #include <sysbus/client.h> #include <webctl/module.h> using webctl::error; using webctl::jstr; using webctl::tr; namespace { using Fields = std::vector<std::pair<std::string, std::string>>; const char *yesNo(bool b) { return b ? "yes" : "no"; } long long num(const std::string &s) { return std::strtoll(s.c_str(), nullptr, 10); } // an snmpdd call; on failure the error object to return json_object *callSnmpdd(sysbus::Client &bus, const char *method, const Fields &args, sysbus::Message &reply) { Fields all = args; all.emplace_back("lang", webctl::language()); reply = bus.callService(snmpdd::Service, method, all); if (reply.type != sysbus::MessageType::Error) return nullptr; const std::string &name = reply.member; if (name == sysbus::error::ServiceUnknown) return error(tr("snmpdd isn't running (Services: snmpdd)", "snmpdd läuft nicht (Dienste: snmpdd)"), 503); int status = name == sysbus::error::AccessDenied ? 403 : name == sysbus::error::InvalidArgs ? 400 : 500; return error(reply.get("message", name), status); } json_object *settingsJson(const sysbus::Message &r) { json_object *o = json_object_new_object(); json_object *report = json_object_new_object(); json_object *services = json_object_new_array(), *targets = json_object_new_array(); json_object *target = nullptr; for (auto &f : r.fields) { const std::string &k = f.first, &v = f.second; if (k == "enabled" || k == "events") json_object_object_add(o, k.c_str(), json_object_new_boolean(v == "yes")); else if (k == "interval") json_object_object_add(o, "interval", json_object_new_int64(num(v))); else if (k == "fs_warning") json_object_object_add(o, "fsWarning", json_object_new_int64(num(v))); else if (k == "fs_critical") json_object_object_add(o, "fsCritical", json_object_new_int64(num(v))); else if (k == "temp_warning") json_object_object_add(o, "tempWarning", json_object_new_int64(num(v))); else if (k == "temp_critical") json_object_object_add(o, "tempCritical", json_object_new_int64(num(v))); else if (k.compare(0, 7, "report_") == 0) json_object_object_add(report, k.c_str() + 7, json_object_new_boolean(v == "yes")); else if (k == "enterprise") json_object_object_add(o, "enterprise", jstr(v)); else if (k == "service") json_object_array_add(services, jstr(v)); else if (k == "target") { target = json_object_new_object(); json_object_array_add(targets, target); json_object_object_add(target, "host", jstr(v)); } else if (target && k == "port") json_object_object_add(target, "port", json_object_new_int64(num(v))); else if (target && (k == "version" || k == "community")) json_object_object_add(target, k.c_str(), jstr(v)); } json_object_object_add(o, "report", report); json_object_object_add(o, "services", services); json_object_object_add(o, "targets", targets); return o; } json_object *statusJson(const sysbus::Message &r) { json_object *o = json_object_new_object(); json_object *problems = json_object_new_array(), *targets = json_object_new_array(); json_object *target = nullptr; for (auto &f : r.fields) { const std::string &k = f.first, &v = f.second; if (k == "target") { target = json_object_new_object(); json_object_array_add(targets, target); json_object_object_add(target, "target", jstr(v)); } else if (target && k == "sent") json_object_object_add(target, "sent", json_object_new_int64(num(v))); else if (target && k == "error") json_object_object_add(target, "error", jstr(v)); else if (k == "last_report") json_object_object_add(o, "lastReport", json_object_new_int64(num(v))); else if (k == "next_report") json_object_object_add(o, "nextReport", json_object_new_int64(num(v))); else if (k == "sent") json_object_object_add(o, "sent", json_object_new_int64(num(v))); else if (k == "status") json_object_object_add(o, "status", jstr(v)); else if (k == "problem") json_object_array_add(problems, jstr(v)); } json_object_object_add(o, "problems", problems); json_object_object_add(o, "targets", targets); return o; } // the settings from the UI as snmpdd's fields; "" or why not std::string settingsFields(json_object *s, Fields &out) { if (!s || !json_object_is_type(s, json_type_object)) return tr("no settings given", "keine Einstellungen angegeben"); out.emplace_back("enabled", yesNo(webctl::boolean(s, "enabled"))); out.emplace_back("interval", std::to_string(webctl::integer(s, "interval", 300))); out.emplace_back("events", yesNo(webctl::boolean(s, "events", true))); json_object *report = nullptr; json_object_object_get_ex(s, "report", &report); for (const char *k : {"raid", "lvm", "filesystems", "disks", "smart_attributes", "services"}) out.emplace_back(std::string("report_") + k, yesNo(report && json_object_is_type(report, json_type_object) ? webctl::boolean(report, k, true) : true)); out.emplace_back("fs_warning", std::to_string(webctl::integer(s, "fsWarning", 85))); out.emplace_back("fs_critical", std::to_string(webctl::integer(s, "fsCritical", 95))); out.emplace_back("temp_warning", std::to_string(webctl::integer(s, "tempWarning", 50))); out.emplace_back("temp_critical", std::to_string(webctl::integer(s, "tempCritical", 60))); json_object *list = nullptr; if (json_object_object_get_ex(s, "services", &list) && list) { if (!json_object_is_type(list, json_type_array)) return tr("services: a list", "services: eine Liste"); for (size_t i = 0; i < json_object_array_length(list); ++i) { const char *name = json_object_get_string(json_object_array_get_idx(list, i)); if (name && *name) out.emplace_back("service", name); } } if (json_object_object_get_ex(s, "targets", &list) && list) { if (!json_object_is_type(list, json_type_array)) return tr("targets: a list", "targets: eine Liste"); for (size_t i = 0; i < json_object_array_length(list); ++i) { json_object *t = json_object_array_get_idx(list, i); if (!t || !json_object_is_type(t, json_type_object)) return tr("invalid target", "ungültiges Ziel"); out.emplace_back("target", webctl::str(t, "host")); out.emplace_back("port", std::to_string(webctl::integer(t, "port", 162))); out.emplace_back("version", webctl::str(t, "version", "2c")); out.emplace_back("community", webctl::str(t, "community")); } } return ""; } json_object *call(const webctl_request *req) { webctl::LanguageScope lang(req->lang); std::string action = req->action; sysbus::Client bus; if (!bus.connect()) return error(tr("sysbus isn't reachable: %1", "sysbus ist nicht erreichbar: %1", {bus.lastError()}), 503); sysbus::Message reply; if (action == "info") { if (json_object *err = callSnmpdd(bus, snmpdd::method::GetSettings, {}, reply)) return err; json_object *settings = settingsJson(reply); if (json_object *err = callSnmpdd(bus, snmpdd::method::GetStatus, {}, reply)) { json_object_put(settings); return err; } json_object *out = json_object_new_object(); json_object_object_add(out, "settings", settings); json_object_object_add(out, "status", statusJson(reply)); // the services to choose from (mounts are file systems) json_object *units = json_object_new_array(); for (auto &u : bus.listUnits()) if (u.kind != "mount") json_object_array_add(units, jstr(u.name)); json_object_object_add(out, "units", units); return out; } if (action == "save") { json_object *s = nullptr; json_object_object_get_ex(req->args, "settings", &s); Fields f; std::string bad = settingsFields(s, f); if (!bad.empty()) return error(bad); if (json_object *err = callSnmpdd(bus, snmpdd::method::SetSettings, f, reply)) return err; return webctl::ok(); } if (action == "send") { if (json_object *err = callSnmpdd(bus, snmpdd::method::SendReport, {}, reply)) return err; json_object *out = json_object_new_object(); json_object_object_add(out, "sent", json_object_new_int64(num(reply.get("sent", "0")))); return out; } return error(tr("unknown action %1", "unbekannte Aktion %1", {action}), 404); } } // namespace WEBCTL_MODULE({WEBCTL_MODULE_ABI, "snmp", "SNMP", 38, nullptr, nullptr, call});
www/modules/snmp.js 0 → 100644 +206 −0 Changes for www/modules/snmp.js: 206 added lines, 0 removed lines. Original line number Diff line number Diff line 'use strict'; // SNMP notifications (backend module "snmp", snmpdd over sysbus) (() => { const {h, t, table, dialog, confirmAction, call, run, toast} = webctl; webctl.translations({de: { 'SNMP notifications': 'SNMP-Benachrichtigungen', 'Send report now': 'Bericht jetzt senden', 'Settings': 'Einstellungen', 'Reports': 'Berichte', 'on': 'an', 'off': 'aus', 'every {0}': 'alle {0}', 'Last report': 'Letzter Bericht', 'Next report': 'Nächster Bericht', 'never': 'nie', 'Status': 'Status', 'Notifications sent': 'Gesendete Benachrichtigungen', 'Not collected': 'Nicht erfasst', 'Enterprise OID': 'Enterprise-OID', 'ok': 'ok', 'warning': 'Warnung', 'critical': 'kritisch', 'unknown': 'unbekannt', 'Targets (NMS)': 'Ziele (NMS)', 'Add target': 'Ziel hinzufügen', 'Host': 'Host', 'Port': 'Port', 'Version': 'Version', 'Community': 'Community', 'Sent': 'Gesendet', 'Error': 'Fehler', 'Edit': 'Bearbeiten', 'Remove': 'Entfernen', 'No targets yet.': 'Noch keine Ziele.', 'Target {0}': 'Ziel {0}', 'New target': 'Neues Ziel', 'Add': 'Hinzufügen', 'Host name or IP address of the network management station.': 'Hostname oder IP-Adresse der Network-Management-Station.', 'Remove target {0}?': 'Ziel {0} entfernen?', 'It gets no more notifications.': 'Es bekommt keine Benachrichtigungen mehr.', 'SNMP settings': 'SNMP-Einstellungen', 'Send reports': 'Berichte senden', 'Interval (seconds)': 'Intervall (Sekunden)', 'Report': 'Berichten', 'RAID arrays': 'RAID-Verbünde', 'LVM volume groups': 'LVM-Volume-Groups', 'File systems': 'Dateisysteme', 'Disks (SMART)': 'Festplatten (SMART)', 'Services': 'Dienste', 'SMART attributes of ATA drives': 'SMART-Attribute der ATA-Laufwerke', 'Disk temperature warning at (°C)': 'Festplatten-Temperatur Warnung ab (°C)', 'critical at (°C)': 'kritisch ab (°C)', 'Drives in standby aren\'t woken up: their last reading is sent.': 'Laufwerke im Standby werden nicht geweckt: ihre letzte Messung wird gesendet.', 'Events at once (SMART warning, self-test failed, service failed, status changed)': 'Ereignisse sofort (SMART-Warnung, Selbsttest fehlgeschlagen, Dienst ausgefallen, Statuswechsel)', 'File system warning at (% used)': 'Dateisystem-Warnung ab (% belegt)', 'critical at (% used)': 'kritisch ab (% belegt)', 'Services reported': 'Berichtete Dienste', 'None ticked: all services.': 'Keiner angehakt: alle Dienste.', 'Settings saved.': 'Einstellungen gespeichert.', '{0} notifications sent.': '{0} Benachrichtigungen gesendet.', 'Target saved.': 'Ziel gespeichert.', 'Target removed.': 'Ziel entfernt.', 'SNMPDD-MIB describes the notifications (/usr/share/snmp/mibs/SNMPDD-MIB.txt); load it into the NMS.': 'SNMPDD-MIB beschreibt die Benachrichtigungen (/usr/share/snmp/mibs/SNMPDD-MIB.txt); im NMS laden.', '{0} s': '{0} s', '{0} min': '{0} min', '{0} h': '{0} h', }}); let root; let data; const stateClass = {ok: 'ok', warning: 'warn', critical: 'danger', unknown: ''}; const when = ts => ts ? new Date(ts * 1000).toLocaleString() : t('never'); const every = s => s % 3600 === 0 ? t('{0} h', s / 3600) : s % 60 === 0 ? t('{0} min', s / 60) : t('{0} s', s); async function load() { const r = await run(() => call('snmp', 'info')); if (!r) return; data = r; draw(); } // the settings as they are, with changes async function save(changes, success) { const s = {...data.settings, ...changes}; delete s.enterprise; await call('snmp', 'save', {settings: s}); toast(success); load(); } function settingsDialog() { const s = data.settings; const units = [...data.units]; for (const u of s.services) if (!units.includes(u)) units.push(u); units.sort(); const report = (key, label) => h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'report_' + key, checked: s.report[key] !== false}), label); dialog({ title: t('SNMP settings'), fields: () => [ h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'enabled', checked: s.enabled}), h('strong', {}, t('Send reports'))), h('label', {}, t('Interval (seconds)'), h('input', {name: 'interval', type: 'number', min: 30, max: 86400, value: s.interval, required: true})), h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'events', checked: s.events}), t('Events at once (SMART warning, self-test failed, service failed, status changed)')), h('label', {}, t('Report')), report('raid', t('RAID arrays')), report('lvm', t('LVM volume groups')), report('filesystems', t('File systems')), report('disks', t('Disks (SMART)')), report('smart_attributes', t('SMART attributes of ATA drives')), report('services', t('Services')), h('p', {class: 'hint'}, t('Drives in standby aren\'t woken up: their last reading is sent.')), h('div', {class: 'row'}, h('label', {}, t('File system warning at (% used)'), h('input', {name: 'fsWarning', type: 'number', min: 1, max: 100, value: s.fsWarning, required: true})), h('label', {}, t('critical at (% used)'), h('input', {name: 'fsCritical', type: 'number', min: 1, max: 100, value: s.fsCritical, required: true}))), h('div', {class: 'row'}, h('label', {}, t('Disk temperature warning at (°C)'), h('input', {name: 'tempWarning', type: 'number', min: 20, max: 100, value: s.tempWarning, required: true})), h('label', {}, t('critical at (°C)'), h('input', {name: 'tempCritical', type: 'number', min: 20, max: 100, value: s.tempCritical, required: true}))), h('label', {}, t('Services reported'), h('div', {class: 'checklist'}, units.map(u => h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'services', value: u, checked: s.services.includes(u)}), u)))), h('p', {class: 'hint'}, t('None ticked: all services.')), ], onSubmit: d => save({ enabled: d.enabled, interval: Number(d.interval), events: d.events, report: {raid: d.report_raid, lvm: d.report_lvm, filesystems: d.report_filesystems, disks: d.report_disks, smart_attributes: d.report_smart_attributes, services: d.report_services}, fsWarning: Number(d.fsWarning), fsCritical: Number(d.fsCritical), tempWarning: Number(d.tempWarning), tempCritical: Number(d.tempCritical), services: d.services || [], }, t('Settings saved.')), }); } function targetDialog(index) { const old = index >= 0 ? data.settings.targets[index] : null; const tg = old || {host: '', port: 162, version: '2c', community: 'public'}; dialog({ title: old ? t('Target {0}', old.host) : t('New target'), submit: old ? undefined : t('Add'), fields: () => [ h('div', {class: 'row'}, h('label', {}, t('Host'), h('input', {name: 'host', value: tg.host, required: true})), h('label', {}, t('Port'), h('input', {name: 'port', type: 'number', min: 1, max: 65535, value: tg.port, required: true}))), h('p', {class: 'hint'}, t('Host name or IP address of the network management station.')), h('div', {class: 'row'}, h('label', {}, t('Version'), h('select', {name: 'version'}, ['2c', '1'].map(v => h('option', {value: v, selected: v === tg.version}, 'SNMPv' + v)))), h('label', {}, t('Community'), h('input', {name: 'community', value: tg.community, required: true, maxlength: 255}))), ], onSubmit: d => { const targets = [...data.settings.targets]; const n = {host: d.host.trim(), port: Number(d.port), version: d.version, community: d.community}; if (old) targets[index] = n; else targets.push(n); return save({targets}, t('Target saved.')); }, }); } async function removeTarget(index) { const tg = data.settings.targets[index]; if (!await confirmAction(t('Remove target {0}?', tg.host), t('It gets no more notifications.'), t('Remove'))) return; const targets = data.settings.targets.filter((_, i) => i !== index); // reports need a target: removing the last one turns them off await run(() => save({targets, enabled: data.settings.enabled && targets.length > 0}, t('Target removed.'))); } async function sendNow(button) { button.disabled = true; const r = await run(() => call('snmp', 'send')); button.disabled = false; if (r) toast(t('{0} notifications sent.', r.sent)); load(); } function draw() { const s = data.settings, st = data.status; const row = (label, value) => h('tr', {}, h('th', {}, label), h('td', {}, value)); const badge = name => h('span', {class: 'badge ' + (stateClass[name] || '')}, t(name || 'unknown')); const key = tg => (tg.host.includes(':') ? '[' + tg.host + ']' : tg.host) + ':' + tg.port; const sendButton = h('button', {onclick: () => sendNow(sendButton), disabled: !s.targets.length}, t('Send report now')); root.replaceChildren( h('section', {class: 'panel'}, h('div', {class: 'toolbar'}, h('h2', {}, t('SNMP notifications')), h('button', {onclick: load}, t('Refresh')), sendButton, h('button', {class: 'primary', onclick: settingsDialog}, t('Settings'))), h('div', {class: 'table-wrap'}, h('table', {class: 'facts'}, h('tbody', {}, row(t('Reports'), s.enabled ? [h('span', {class: 'badge ok'}, t('on')), ' ', t('every {0}', every(s.interval))] : h('span', {class: 'badge'}, t('off'))), row(t('Last report'), when(st.lastReport)), row(t('Next report'), s.enabled ? when(st.nextReport) : '–'), row(t('Status'), st.lastReport ? badge(st.status) : '–'), st.problems.length ? row(t('Not collected'), st.problems.join('; ')) : null, row(t('Notifications sent'), String(st.sent || 0)), row(t('Enterprise OID'), h('span', {class: 'mono'}, s.enterprise || '')))))), h('section', {class: 'panel'}, h('div', {class: 'toolbar'}, h('h2', {}, t('Targets (NMS)')), h('button', {onclick: () => targetDialog(-1)}, t('Add target'))), table([ {title: t('Host'), render: tg => h('strong', {}, tg.host)}, {title: t('Port'), render: tg => tg.port, class: 'mono'}, {title: t('Version'), render: tg => 'SNMPv' + tg.version}, {title: t('Community'), render: tg => h('span', {class: 'mono'}, tg.community)}, {title: t('Sent'), render: tg => String((st.targets.find(x => x.target === key(tg)) || {}).sent || 0)}, {title: t('Error'), render: tg => { const e = (st.targets.find(x => x.target === key(tg)) || {}).error; return e ? h('span', {class: 'badge danger'}, e) : ''; }}, {title: '', class: 'actions', render: tg => { const i = s.targets.indexOf(tg); return [h('button', {class: 'small', onclick: () => targetDialog(i)}, t('Edit')), h('button', {class: 'small danger', onclick: () => removeTarget(i)}, t('Remove'))]; }}, ], s.targets, t('No targets yet.')), h('p', {class: 'hint'}, t('SNMPDD-MIB describes the notifications (/usr/share/snmp/mibs/SNMPDD-MIB.txt); load it into the NMS.')))); } webctl.register('snmp', { render(container) { root = container; root.append(h('p', {class: 'empty'}, t('Loading …'))); load(); const timer = setInterval(() => { if (!document.querySelector('dialog')) load(); }, 15000); return () => clearInterval(timer); }, }); })();