# 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 texts: dict[str, str] = field(default_factory=dict) # keys answered in words, not a number @dataclass class TableScan: rows: dict[str, list[dict]] = field(default_factory=dict) # table id -> [{"index", "label", "columns"}] error: str | None = None 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 answers in words (a vendor's status), the keys the device answered without, 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): text = str(val).strip() if text: result.texts[key] = text else: result.missing.append(key) if not result.readings and not result.texts 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) # Rows kept of one table: enough for any NAS's disks and volumes. ROWS_PER_TABLE = 64 # Entries walked of one column, before rows_like keeps the physical disks: a # Synology's UCD-DISKIO lists RAM disks, loops, partitions and md arrays ahead # of a disk added late, so the bound is on the walk, not on the rows kept, and # it only stops a device that answers a walk forever. ENTRIES_PER_COLUMN = 1024 async def _walk_column(api, engine, auth, transport, column: str) -> dict[str, object] | str: """{row index: value} for one column, or the error in words when the device didn't answer. A column the device lacks is an empty dict.""" found: dict[str, object] = {} 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: return str(ei) if es: break for name, val in rows: oid = str(name) if type(val).__name__ in _ABSENT or not oid.startswith(column + "."): continue found[oid[len(column) + 1:]] = val if len(found) >= ENTRIES_PER_COLUMN: break return found async def walk_tables(address: str, port: int, creds: Credentials, tables: list) -> TableScan: """Which rows each vendor table has: each row's index, its label, and the columns it answered. A walk per column, so it is asked for by the scheduled poll when a device's rows are unknown or an hour old, never on a page load. Never raises.""" scan = TableScan() if not tables: return scan if not _pysnmp_available(): scan.error = "pysnmp isn't installed in this image, so SNMP polling is off." return scan api = _api() engine = api.hl.SnmpEngine() try: try: transport = await _transport(api, address, port) except Exception as exc: scan.error = f"Can't reach {address}:{port}: {exc}" return scan auth = _auth_data(api, creds) for table in tables: labels: dict[str, object] = {} if table.label: got = await _walk_column(api, engine, auth, transport, table.label) if isinstance(got, str): scan.error = explain(got, address, port, creds) return scan labels = got answered: dict[str, set[str]] = {} for column in table.columns: got = await _walk_column(api, engine, auth, transport, column.oid) if isinstance(got, str): scan.error = explain(got, address, port, creds) return scan for index in got: answered.setdefault(index, set()).add(column.key) indexes = labels if table.label else answered rows = [] for index in sorted(indexes, key=lambda i: [int(p) for p in i.split(".") if p.isdigit()]): label = str(labels.get(index, "")).strip() if table.label and not table.keeps(label): continue columns = [c.key for c in table.columns if c.key in answered.get(index, ())] if columns: rows.append({"index": index, "label": label, "columns": columns}) scan.rows[table.id] = rows[:ROWS_PER_TABLE] return scan except Exception as exc: logger.warning("SNMP table walk of %s:%s failed: %s", address, port, exc) scan.error = f"SNMP table walk failed: {exc}" return scan 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)