Fix hang in SNMP device_tracker implementation (#112815)

Co-authored-by: J. Nick Koston <nick@koston.org>
pull/115186/head
Niccolò Maggioni 2024-04-08 10:04:59 +02:00 committed by Franck Nijhof
parent 95606135a6
commit 61a359e4d2
No known key found for this signature in database
GPG Key ID: D62583BA8AB11CA3
3 changed files with 110 additions and 48 deletions

View File

@ -1249,6 +1249,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/sms/ @ocalvo /homeassistant/components/sms/ @ocalvo
/homeassistant/components/snapcast/ @luar123 /homeassistant/components/snapcast/ @luar123
/tests/components/snapcast/ @luar123 /tests/components/snapcast/ @luar123
/homeassistant/components/snmp/ @nmaggioni
/tests/components/snmp/ @nmaggioni
/homeassistant/components/snooz/ @AustinBrunkhorst /homeassistant/components/snooz/ @AustinBrunkhorst
/tests/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst
/homeassistant/components/solaredge/ @frenck /homeassistant/components/solaredge/ @frenck

View File

@ -5,8 +5,19 @@ from __future__ import annotations
import binascii import binascii
import logging import logging
from pysnmp.entity import config as cfg from pysnmp.error import PySnmpError
from pysnmp.entity.rfc3413.oneliner import cmdgen from pysnmp.hlapi.asyncio import (
CommunityData,
ContextData,
ObjectIdentity,
ObjectType,
SnmpEngine,
Udp6TransportTarget,
UdpTransportTarget,
UsmUserData,
bulkWalkCmd,
isEndOfMib,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.components.device_tracker import ( from homeassistant.components.device_tracker import (
@ -24,7 +35,13 @@ from .const import (
CONF_BASEOID, CONF_BASEOID,
CONF_COMMUNITY, CONF_COMMUNITY,
CONF_PRIV_KEY, CONF_PRIV_KEY,
DEFAULT_AUTH_PROTOCOL,
DEFAULT_COMMUNITY, DEFAULT_COMMUNITY,
DEFAULT_PORT,
DEFAULT_PRIV_PROTOCOL,
DEFAULT_TIMEOUT,
DEFAULT_VERSION,
SNMP_VERSIONS,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -40,9 +57,12 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend(
) )
def get_scanner(hass: HomeAssistant, config: ConfigType) -> SnmpScanner | None: async def async_get_scanner(
hass: HomeAssistant, config: ConfigType
) -> SnmpScanner | None:
"""Validate the configuration and return an SNMP scanner.""" """Validate the configuration and return an SNMP scanner."""
scanner = SnmpScanner(config[DOMAIN]) scanner = SnmpScanner(config[DOMAIN])
await scanner.async_init()
return scanner if scanner.success_init else None return scanner if scanner.success_init else None
@ -51,39 +71,75 @@ class SnmpScanner(DeviceScanner):
"""Queries any SNMP capable Access Point for connected devices.""" """Queries any SNMP capable Access Point for connected devices."""
def __init__(self, config): def __init__(self, config):
"""Initialize the scanner.""" """Initialize the scanner and test the target device."""
host = config[CONF_HOST]
community = config[CONF_COMMUNITY]
baseoid = config[CONF_BASEOID]
authkey = config.get(CONF_AUTH_KEY)
authproto = DEFAULT_AUTH_PROTOCOL
privkey = config.get(CONF_PRIV_KEY)
privproto = DEFAULT_PRIV_PROTOCOL
self.snmp = cmdgen.CommandGenerator() try:
# Try IPv4 first.
self.host = cmdgen.UdpTransportTarget((config[CONF_HOST], 161)) target = UdpTransportTarget((host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT)
if CONF_AUTH_KEY not in config or CONF_PRIV_KEY not in config: except PySnmpError:
self.auth = cmdgen.CommunityData(config[CONF_COMMUNITY]) # Then try IPv6.
else: try:
self.auth = cmdgen.UsmUserData( target = Udp6TransportTarget(
config[CONF_COMMUNITY], (host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT
config[CONF_AUTH_KEY],
config[CONF_PRIV_KEY],
authProtocol=cfg.usmHMACSHAAuthProtocol,
privProtocol=cfg.usmAesCfb128Protocol,
) )
self.baseoid = cmdgen.MibVariable(config[CONF_BASEOID]) except PySnmpError as err:
self.last_results = [] _LOGGER.error("Invalid SNMP host: %s", err)
return
# Test the router is accessible if authkey is not None or privkey is not None:
data = self.get_snmp_data() if not authkey:
authproto = "none"
if not privkey:
privproto = "none"
request_args = [
SnmpEngine(),
UsmUserData(
community,
authKey=authkey or None,
privKey=privkey or None,
authProtocol=authproto,
privProtocol=privproto,
),
target,
ContextData(),
]
else:
request_args = [
SnmpEngine(),
CommunityData(community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION]),
target,
ContextData(),
]
self.request_args = request_args
self.baseoid = baseoid
self.last_results = []
self.success_init = False
async def async_init(self):
"""Make a one-off read to check if the target device is reachable and readable."""
data = await self.async_get_snmp_data()
self.success_init = data is not None self.success_init = data is not None
def scan_devices(self): async def async_scan_devices(self):
"""Scan for new devices and return a list with found device IDs.""" """Scan for new devices and return a list with found device IDs."""
self._update_info() await self._async_update_info()
return [client["mac"] for client in self.last_results if client.get("mac")] return [client["mac"] for client in self.last_results if client.get("mac")]
def get_device_name(self, device): async def async_get_device_name(self, device):
"""Return the name of the given device or None if we don't know.""" """Return the name of the given device or None if we don't know."""
# We have no names # We have no names
return None return None
def _update_info(self): async def _async_update_info(self):
"""Ensure the information from the device is up to date. """Ensure the information from the device is up to date.
Return boolean if scanning successful. Return boolean if scanning successful.
@ -91,20 +147,24 @@ class SnmpScanner(DeviceScanner):
if not self.success_init: if not self.success_init:
return False return False
if not (data := self.get_snmp_data()): if not (data := await self.async_get_snmp_data()):
return False return False
self.last_results = data self.last_results = data
return True return True
def get_snmp_data(self): async def async_get_snmp_data(self):
"""Fetch MAC addresses from access point via SNMP.""" """Fetch MAC addresses from access point via SNMP."""
devices = [] devices = []
errindication, errstatus, errindex, restable = self.snmp.nextCmd( walker = bulkWalkCmd(
self.auth, self.host, self.baseoid *self.request_args,
0,
50,
ObjectType(ObjectIdentity(self.baseoid)),
lexicographicMode=False,
) )
async for errindication, errstatus, errindex, res in walker:
if errindication: if errindication:
_LOGGER.error("SNMPLIB error: %s", errindication) _LOGGER.error("SNMPLIB error: %s", errindication)
return return
@ -112,14 +172,14 @@ class SnmpScanner(DeviceScanner):
_LOGGER.error( _LOGGER.error(
"SNMP error: %s at %s", "SNMP error: %s at %s",
errstatus.prettyPrint(), errstatus.prettyPrint(),
errindex and restable[int(errindex) - 1][0] or "?", errindex and res[int(errindex) - 1][0] or "?",
) )
return return
for resrow in restable: for _oid, value in res:
for _, val in resrow: if not isEndOfMib(res):
try: try:
mac = binascii.hexlify(val.asOctets()).decode("utf-8") mac = binascii.hexlify(value.asOctets()).decode("utf-8")
except AttributeError: except AttributeError:
continue continue
_LOGGER.debug("Found MAC address: %s", mac) _LOGGER.debug("Found MAC address: %s", mac)

View File

@ -1,7 +1,7 @@
{ {
"domain": "snmp", "domain": "snmp",
"name": "SNMP", "name": "SNMP",
"codeowners": [], "codeowners": ["@nmaggioni"],
"documentation": "https://www.home-assistant.io/integrations/snmp", "documentation": "https://www.home-assistant.io/integrations/snmp",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyasn1", "pysmi", "pysnmp"], "loggers": ["pyasn1", "pysmi", "pysnmp"],