Files
FabledSteward/plugins/snmp/service.py
T
bvandeusenandClaude Opus 5.5 9ca2e38fa1
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 55s
CI / unit (push) Successful in 1m24s
CI / publish (push) Skipped
feat(snmp): devices you add, test and read on the SNMP page (milestone 491)
The operator, reviewing SNMP on #5214: "this system looks incomplete like
there's something missing from it". The one device shown was plugin.yaml's
example (core-switch 192.168.1.1, community public), polled every minute;
devices could only be defined by editing YAML in the image; a failed poll was
logged at DEBUG and stored nowhere, so the page said "waiting for the next
poll" forever; v3 ran as v2c; counters showed as lifetime totals.

- Devices are rows (snmp_devices, migration snmp_001): address, port,
  v1/v2c community or a v3 user with auth/priv protocols and keys (secrets
  encrypted), presets plus custom readings, an optional host binding, and
  the newest poll's outcome. The YAML device list and its example are gone.
- The SNMP page adds, edits and deletes them (admin, audited, with
  get_audit_entities so the audit log names and links a device). Readings
  come from presets (system uptime, interface traffic by number, UPS-MIB,
  Linux net-snmp) and custom "OID name [unit]" lines.
- One GET per device with every OID, devices polled at once. The outcome is
  written to the row in words ("No reply from … within 5 s. Check the address
  and port, that SNMP is on, and the community"), with readings it lacks.
  v1's whole-request error drops that OID and asks again. SNMPv3 signs in
  with USM (MD5/SHA-1/SHA-2, DES/3DES/AES).
- Test asks the unsaved form's device for its name, description, uptime and
  interfaces by number. It is an admin's explicit action; every page reads
  only stored rows (System 178's ruling).
- Each reading has a unit; a counter is stored as a per-second rate (bytes as
  bit/s), a reset or wrap skipped. The page, widget and host panel say values
  in units and why a device is silent; the device page draws a chart per
  reading on a dated axis.

Tests: readings and presets, the form and its secrets, v3 checks, rates,
the poller against a stand-in pysnmp (one GET, v1 retry, timeout wording,
USM), the pages, and an integration test of the poll writing outcomes and
rates on Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:54:11 -04:00

196 lines
8.9 KiB
Python

# plugins/snmp/service.py
"""SNMP devices: the form a person edits, the row it becomes, what the poller
asks for, and what a page says about the newest poll."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from steward.core.crypto import decrypt_secret, encrypt_secret
from .models import SnmpDevice
from .poller import AUTH_PROTOCOLS, PRIV_PROTOCOLS, Credentials
from .readings import PRESETS, Reading, device_readings, parse_interfaces
VERSIONS = ("1", "2c", "3")
PRESET_IDS = set(PRESETS) | {"interfaces"}
SECRET_FIELDS = ("community", "v3_auth_key", "v3_priv_key")
class FormError(ValueError):
"""What is wrong with a submitted device form, said to the person."""
def readings_for(device: SnmpDevice) -> list[Reading]:
"""What the device is asked for. A row saved through the form always
parses; one that somehow doesn't is asked for nothing rather than raising
in the poll loop."""
try:
return device_readings(device.presets or [], parse_interfaces(device.interfaces),
device.custom, device.version)
except ValueError:
return []
def credentials(device: SnmpDevice) -> Credentials:
return Credentials(
version=device.version,
community=decrypt_secret(device.community, context=f"snmp device {device.name} community"),
user=device.v3_user, auth=device.v3_auth,
auth_key=decrypt_secret(device.v3_auth_key, context=f"snmp device {device.name} auth key"),
priv=device.v3_priv,
priv_key=decrypt_secret(device.v3_priv_key, context=f"snmp device {device.name} privacy key"))
def form_values(device: SnmpDevice | None) -> dict:
"""The form's fields for an existing device. Secrets are never sent back
to the browser; a blank secret field on save keeps the stored one."""
if device is None:
return {"port": 161, "version": "2c", "presets": ["system"], "v3_auth": "sha", "v3_priv": "aes128"}
return {"name": device.name, "address": device.address, "port": device.port,
"version": device.version, "v3_user": device.v3_user, "v3_auth": device.v3_auth,
"v3_priv": device.v3_priv, "presets": list(device.presets or []),
"interfaces": device.interfaces, "custom": device.custom, "host_id": device.host_id or "",
"has_community": bool(device.community), "has_auth_key": bool(device.v3_auth_key),
"has_priv_key": bool(device.v3_priv_key)}
def parse_form(form, existing: SnmpDevice | None = None) -> dict:
"""The submitted form as the columns to store, secrets encrypted.
Raises FormError saying what to fix."""
name = (form.get("name") or "").strip()
address = (form.get("address") or "").strip()
if not name:
raise FormError("Give the device a name.")
if len(name) > 100 or "/" in name:
raise FormError("A name is up to 100 characters, without a slash.")
if not address:
raise FormError("Give the device's address: an IP or a hostname.")
try:
port = int(form.get("port") or 161)
except ValueError:
raise FormError("The port is a number, usually 161.") from None
if not 1 <= port <= 65535:
raise FormError("The port is between 1 and 65535, usually 161.")
version = form.get("version") or "2c"
if version not in VERSIONS:
raise FormError("Choose SNMP version 1, 2c or 3.")
presets = [p for p in form.getlist("presets") if p in PRESET_IDS]
interfaces = (form.get("interfaces") or "").strip()
custom = (form.get("custom") or "").strip()
try:
numbers = parse_interfaces(interfaces)
if "interfaces" in presets and not numbers:
raise FormError("Interface traffic needs the interface numbers to read, e.g. 1, 2 or 1-4.")
if not device_readings(presets, numbers, custom, version):
raise FormError("Choose at least one preset or add a custom reading.")
except ValueError as exc:
raise FormError(str(exc)) from None
def secret(field: str) -> str:
typed = form.get(field) or ""
if typed:
return encrypt_secret(typed)
return getattr(existing, field) if existing is not None else ""
values = {"name": name, "address": address, "port": port, "version": version,
"presets": presets, "interfaces": interfaces, "custom": custom,
"host_id": (form.get("host_id") or "").strip() or None,
"community": "", "v3_user": "", "v3_auth": "none", "v3_auth_key": "",
"v3_priv": "none", "v3_priv_key": ""}
if version == "3":
user = (form.get("v3_user") or "").strip()
auth = form.get("v3_auth") or "none"
priv = form.get("v3_priv") or "none"
if not user:
raise FormError("SNMPv3 needs a user name.")
if auth not in AUTH_PROTOCOLS or priv not in PRIV_PROTOCOLS:
raise FormError("Choose an authentication and a privacy protocol from the lists.")
if priv != "none" and auth == "none":
raise FormError("Privacy needs authentication too: choose an authentication protocol.")
values.update(v3_user=user, v3_auth=auth, v3_priv=priv,
v3_auth_key=secret("v3_auth_key") if auth != "none" else "",
v3_priv_key=secret("v3_priv_key") if priv != "none" else "")
for field, proto in (("v3_auth_key", auth), ("v3_priv_key", priv)):
if proto != "none" and not values[field]:
raise FormError("SNMPv3 with " + ("authentication" if field == "v3_auth_key" else "privacy")
+ " needs its key (at least 8 characters).")
if proto != "none" and form.get(field) and len(form.get(field)) < 8:
raise FormError("An SNMPv3 key is at least 8 characters.")
else:
values["community"] = secret("community") or encrypt_secret("public")
return values
def form_credentials(form, existing: SnmpDevice | None = None) -> tuple[str, int, Credentials]:
"""Address, port and credentials from an unsaved form, for Test. A blank
secret falls back to the stored one when testing an existing device."""
def secret(field: str, default: str = "") -> str:
typed = form.get(field) or ""
if typed:
return typed
if existing is not None and getattr(existing, field):
return decrypt_secret(getattr(existing, field))
return default
version = form.get("version") if form.get("version") in VERSIONS else "2c"
try:
port = int(form.get("port") or 161)
except ValueError:
port = 161
creds = Credentials(version=version, community=secret("community", "public"),
user=(form.get("v3_user") or "").strip(), auth=form.get("v3_auth") or "none",
auth_key=secret("v3_auth_key"), priv=form.get("v3_priv") or "none",
priv_key=secret("v3_priv_key"))
return (form.get("address") or "").strip(), port, creds
# A device not polled for this many intervals has stopped being polled
# (plugin turned off, scheduler stuck), whatever its last outcome said.
STALE_POLLS = 3
@dataclass
class DeviceState:
reach: str # up | down | stale | pending
says: str # one line, in words
def device_state(device: SnmpDevice, poll_seconds: int, now: datetime) -> DeviceState:
"""What the newest poll says about the device."""
if device.last_polled_at is None:
return DeviceState("pending", "Not polled yet.")
if (now - device.last_polled_at).total_seconds() > STALE_POLLS * max(int(poll_seconds), 1):
return DeviceState("stale", "Not polled recently: the SNMP plugin may be turned off.")
if device.last_error:
return DeviceState("down", device.last_error)
return DeviceState("up", "Answering.")
class RateKeeper:
"""A counter's previous sample per (device, key), to turn the next one
into a per-second rate. In memory: after a restart the first poll of each
counter stores nothing and the second stores its rate. A sample lower
than the one before (the device restarted, or a 32-bit counter wrapped)
is skipped rather than read as a huge negative or wrapped rate."""
def __init__(self) -> None:
self._last: dict[tuple[str, str], tuple[float, datetime]] = {}
def rate(self, device_id: str, reading: Reading, value: float, at: datetime) -> float | None:
from .readings import COUNTER_UNITS
key = (device_id, reading.key)
prev = self._last.get(key)
self._last[key] = (value, at)
if prev is None:
return None
seconds = (at - prev[1]).total_seconds()
if seconds <= 0 or value < prev[0]:
return None
return (value - prev[0]) / seconds * COUNTER_UNITS[reading.unit][1]
def forget(self, device_id: str) -> None:
for key in [k for k in self._last if k[0] == device_id]:
del self._last[key]