Files
FabledSteward/plugins/snmp/poller.py
T
bvandeusenandClaude Opus 5.5 bd1c32d2a4
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Successful in 1m50s
CI / publish (push) Successful in 1m29s
fix(snmp): a disk listed late in UCD-DISKIO is read, and Asustor disks are named by bay (#5992)
The table walk stopped after 64 raw entries, counting the RAM disks, loops,
partitions and md arrays that rows_like then drops, so a Synology's eighth
disk was never reached. The walk is now bounded at 1,024 entries per column;
64 still caps the rows kept.

ADM labels its disks "Disk0".."Disk4" while their indexes run 1..5; the
Asustor disk table now names each row by its index ("Disk 1"). Keys are
unchanged, so history carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:16:25 -04:00

365 lines
15 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
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)