#5470 "pysnmp-lextudio is deprecated: every startup logs a RuntimeWarning". Dependency change: the [snmp] extra now installs `pysnmp>=7.1` instead of `pysnmp-lextudio>=6.2`. pysnmp is the same lextudio lineage under its maintained name; the 7.1 floor is because below 7 the name is the abandoned 4.x line with no asyncio API. The poller already finds either API shape (_api(), the engine close probes), so no code changes; its docstring now names what the image ships. Approved by the operator 2026-10-09 ("yes please do the swap"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
268 lines
11 KiB
Python
268 lines
11 KiB
Python
# plugins/snmp/poller.py
|
|
"""Ask an SNMP device for its readings, and say in words why it didn't answer.
|
|
|
|
pysnmp 7.x (what the image installs, #5470) and the deprecated
|
|
pysnmp-lextudio 6.x put the asyncio API in different modules with different
|
|
names; _api() finds whichever is installed, so a dependency change can't
|
|
silently break polling. Without pysnmp at all the poll reports that instead
|
|
of raising.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from types import SimpleNamespace
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TIMEOUT_SECONDS = 5
|
|
# OIDs per GET. A device's whole list usually fits in one request; this keeps
|
|
# a long custom list under the size an agent will answer.
|
|
OIDS_PER_REQUEST = 24
|
|
|
|
AUTH_PROTOCOLS = {"none": "usmNoAuthProtocol", "md5": "usmHMACMD5AuthProtocol",
|
|
"sha": "usmHMACSHAAuthProtocol", "sha224": "usmHMAC128SHA224AuthProtocol",
|
|
"sha256": "usmHMAC192SHA256AuthProtocol", "sha384": "usmHMAC256SHA384AuthProtocol",
|
|
"sha512": "usmHMAC384SHA512AuthProtocol"}
|
|
PRIV_PROTOCOLS = {"none": "usmNoPrivProtocol", "des": "usmDESPrivProtocol", "3des": "usm3DESEDEPrivProtocol",
|
|
"aes128": "usmAesCfb128Protocol", "aes192": "usmAesCfb192Protocol",
|
|
"aes256": "usmAesCfb256Protocol"}
|
|
|
|
# A varbind value of one of these types means the agent has no such object.
|
|
_ABSENT = {"NoSuchObject", "NoSuchInstance", "EndOfMibView"}
|
|
|
|
|
|
@dataclass
|
|
class Credentials:
|
|
version: str = "2c"
|
|
community: str = ""
|
|
user: str = ""
|
|
auth: str = "none"
|
|
auth_key: str = ""
|
|
priv: str = "none"
|
|
priv_key: str = ""
|
|
|
|
|
|
@dataclass
|
|
class PollResult:
|
|
readings: dict[str, float] = field(default_factory=dict)
|
|
error: str | None = None # the device didn't answer, and why
|
|
missing: list[str] = field(default_factory=list) # keys it answered without
|
|
|
|
|
|
def _pysnmp_available() -> bool:
|
|
try:
|
|
import pysnmp # noqa: F401
|
|
return True
|
|
except ImportError:
|
|
return False
|
|
|
|
|
|
def _api() -> SimpleNamespace:
|
|
"""The installed pysnmp's asyncio API under one set of names."""
|
|
try:
|
|
from pysnmp.hlapi.v3arch import asyncio as hl
|
|
from pysnmp.hlapi.v3arch.asyncio import auth
|
|
return SimpleNamespace(hl=hl, auth=auth, get=hl.get_cmd, walk=hl.walk_cmd, async_transport=True)
|
|
except ImportError:
|
|
from pysnmp.hlapi import asyncio as hl
|
|
from pysnmp.hlapi.asyncio import auth
|
|
return SimpleNamespace(hl=hl, auth=auth, get=hl.getCmd, walk=hl.walkCmd, async_transport=False)
|
|
|
|
|
|
def _auth_data(api, creds: Credentials):
|
|
if creds.version == "3":
|
|
return api.hl.UsmUserData(
|
|
creds.user,
|
|
authKey=creds.auth_key or None,
|
|
privKey=creds.priv_key or None,
|
|
authProtocol=getattr(api.auth, AUTH_PROTOCOLS.get(creds.auth, "usmNoAuthProtocol")),
|
|
privProtocol=getattr(api.auth, PRIV_PROTOCOLS.get(creds.priv, "usmNoPrivProtocol")))
|
|
return api.hl.CommunityData(creds.community, mpModel=0 if creds.version == "1" else 1)
|
|
|
|
|
|
def _close_engine(engine) -> None:
|
|
"""Release the engine's UDP transport socket.
|
|
|
|
pysnmp opens a UDP socket per ``SnmpEngine`` and never closes it on its own.
|
|
Since we build a fresh engine for every poll, an unclosed engine leaks one
|
|
file descriptor each scheduler tick; over hours of polling that exhausts the
|
|
process fd limit (``OSError: [Errno 24] Too many open files``), which also
|
|
takes down the app's listening socket. So close it explicitly here.
|
|
|
|
The close method/attribute names differ across pysnmp majors (6.2.x lextudio
|
|
is camelCase, canonical 7.x is snake_case), so probe for whatever exists.
|
|
All paths are best-effort — a failed close must never break the poll loop.
|
|
"""
|
|
# 7.x exposes a convenience close directly on the engine.
|
|
for meth in ("close_dispatcher", "closeDispatcher"):
|
|
fn = getattr(engine, meth, None)
|
|
if callable(fn):
|
|
try:
|
|
fn()
|
|
except Exception:
|
|
pass
|
|
return
|
|
# 6.2.x: go through the transport dispatcher.
|
|
for attr in ("transport_dispatcher", "transportDispatcher"):
|
|
dispatcher = getattr(engine, attr, None)
|
|
if dispatcher is None:
|
|
continue
|
|
for meth in ("close_dispatcher", "closeDispatcher"):
|
|
fn = getattr(dispatcher, meth, None)
|
|
if callable(fn):
|
|
try:
|
|
fn()
|
|
except Exception:
|
|
pass
|
|
return
|
|
return
|
|
|
|
|
|
def explain(error_indication, address: str, port: int, creds: Credentials) -> str:
|
|
"""pysnmp's error indication, said as what to check."""
|
|
text = str(error_indication)
|
|
low = text.lower()
|
|
if "timeout" in low or "no snmp response" in low:
|
|
who = (f"the v3 user “{creds.user}”" if creds.version == "3"
|
|
else "the community")
|
|
return (f"No reply from {address}:{port} within {TIMEOUT_SECONDS} s. Check the address and port, "
|
|
f"that SNMP is on, and {who}: a device ignores a request it doesn't accept.")
|
|
if "unknown" in low and "user" in low:
|
|
return f"The device doesn't know the v3 user “{creds.user}”."
|
|
if "digest" in low or "authentication" in low:
|
|
return "The device refused the v3 authentication: check the auth protocol and key."
|
|
if "decrypt" in low or "privacy" in low:
|
|
return "The device couldn't decrypt the request: check the privacy protocol and key."
|
|
if "not in time window" in low:
|
|
return "The device rejected the request as out of its time window; try again."
|
|
return f"SNMP error from {address}:{port}: {text}"
|
|
|
|
|
|
async def _transport(api, address: str, port: int):
|
|
if api.async_transport:
|
|
return await api.hl.UdpTransportTarget.create((address, port), timeout=TIMEOUT_SECONDS, retries=1)
|
|
return api.hl.UdpTransportTarget((address, port), timeout=TIMEOUT_SECONDS, retries=1)
|
|
|
|
|
|
async def poll_device(address: str, port: int, creds: Credentials,
|
|
oids: list[tuple[str, str]]) -> PollResult:
|
|
"""GET every (oid, key) of a device, a few dozen to a request.
|
|
|
|
Returns the numeric answers by key, the keys the device answered without
|
|
(no such object, or not a number), and an error in words when it didn't
|
|
answer at all. Never raises: a broken device must not stop the others.
|
|
"""
|
|
result = PollResult()
|
|
if not oids:
|
|
return result
|
|
if not _pysnmp_available():
|
|
result.error = "pysnmp isn't installed in this image, so SNMP polling is off."
|
|
return result
|
|
|
|
api = _api()
|
|
engine = api.hl.SnmpEngine()
|
|
try:
|
|
try:
|
|
transport = await _transport(api, address, port)
|
|
except Exception as exc:
|
|
result.error = f"Can't reach {address}:{port}: {exc}"
|
|
return result
|
|
auth = _auth_data(api, creds)
|
|
|
|
pending = list(oids)
|
|
while pending:
|
|
chunk, pending = pending[:OIDS_PER_REQUEST], pending[OIDS_PER_REQUEST:]
|
|
ei, es, eidx, var_binds = await api.get(
|
|
engine, auth, transport, api.hl.ContextData(),
|
|
*[api.hl.ObjectType(api.hl.ObjectIdentity(oid)) for oid, _ in chunk],
|
|
lookupMib=False)
|
|
if ei:
|
|
result.error = explain(ei, address, port, creds)
|
|
return result
|
|
if es:
|
|
# v1 fails the whole request on the first unknown OID: note it,
|
|
# and ask again for the rest.
|
|
bad = int(eidx) - 1 if eidx else -1
|
|
if 0 <= bad < len(chunk):
|
|
result.missing.append(chunk[bad][1])
|
|
pending = chunk[:bad] + chunk[bad + 1:] + pending
|
|
continue
|
|
result.error = f"The device answered with an error: {es.prettyPrint()}"
|
|
return result
|
|
# A GET answers in the order it asked.
|
|
for (_, key), (_, val) in zip(chunk, var_binds):
|
|
if type(val).__name__ in _ABSENT:
|
|
result.missing.append(key)
|
|
continue
|
|
try:
|
|
result.readings[key] = float(val)
|
|
except (TypeError, ValueError):
|
|
result.missing.append(key)
|
|
if not result.readings and result.missing:
|
|
result.error = "The device answered but has none of these readings."
|
|
return result
|
|
except Exception as exc:
|
|
logger.warning("SNMP poll of %s:%s failed: %s", address, port, exc)
|
|
result.error = f"SNMP poll failed: {exc}"
|
|
return result
|
|
finally:
|
|
_close_engine(engine)
|
|
|
|
|
|
SYS_DESCR, SYS_UPTIME, SYS_NAME = "1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.3.0", "1.3.6.1.2.1.1.5.0"
|
|
IF_DESCR, IF_NAME = "1.3.6.1.2.1.2.2.1.2", "1.3.6.1.2.1.31.1.1.1.1"
|
|
INTERFACES_LISTED = 128
|
|
|
|
|
|
async def describe_device(address: str, port: int, creds: Credentials) -> dict:
|
|
"""What Test shows: {"error"} or {"name", "description", "uptime_cs",
|
|
"interfaces": [(number, name)]}. Asks the device directly, so only an
|
|
operator's explicit Test calls it, never a page load."""
|
|
if not _pysnmp_available():
|
|
return {"error": "pysnmp isn't installed in this image, so SNMP is off."}
|
|
api = _api()
|
|
engine = api.hl.SnmpEngine()
|
|
try:
|
|
try:
|
|
transport = await _transport(api, address, port)
|
|
except Exception as exc:
|
|
return {"error": f"Can't reach {address}:{port}: {exc}"}
|
|
auth = _auth_data(api, creds)
|
|
ei, es, _, var_binds = await api.get(
|
|
engine, auth, transport, api.hl.ContextData(),
|
|
*[api.hl.ObjectType(api.hl.ObjectIdentity(o)) for o in (SYS_NAME, SYS_DESCR, SYS_UPTIME)],
|
|
lookupMib=False)
|
|
if ei:
|
|
return {"error": explain(ei, address, port, creds)}
|
|
if es:
|
|
return {"error": f"The device answered with an error: {es.prettyPrint()}"}
|
|
values = [None if type(v).__name__ in _ABSENT else v for _, v in var_binds]
|
|
out = {"name": str(values[0]) if values[0] is not None else "",
|
|
"description": str(values[1]) if values[1] is not None else "",
|
|
"uptime_cs": float(values[2]) if values[2] is not None else None,
|
|
"interfaces": []}
|
|
# ifName where the device has it (IF-MIB), else ifDescr.
|
|
for column in (IF_NAME, IF_DESCR):
|
|
found: list[tuple[int, str]] = []
|
|
async for ei, es, _, rows in api.walk(engine, auth, transport, api.hl.ContextData(),
|
|
api.hl.ObjectType(api.hl.ObjectIdentity(column)),
|
|
lexicographicMode=False, lookupMib=False):
|
|
if ei or es:
|
|
break
|
|
for name, val in rows:
|
|
if type(val).__name__ in _ABSENT:
|
|
continue
|
|
found.append((int(str(name).rsplit(".", 1)[-1]), str(val)))
|
|
if len(found) >= INTERFACES_LISTED:
|
|
break
|
|
if found:
|
|
out["interfaces"] = found
|
|
break
|
|
return out
|
|
except Exception as exc:
|
|
logger.warning("SNMP test of %s:%s failed: %s", address, port, exc)
|
|
return {"error": f"SNMP test failed: {exc}"}
|
|
finally:
|
|
_close_engine(engine)
|