SNMP discovery updates continued

This commit is contained in:
Keith Smith
2026-05-26 17:43:33 -06:00
parent e59733d331
commit 6ff452a8a9
10 changed files with 554 additions and 36 deletions
+195 -11
View File
@@ -1,5 +1,6 @@
from dataclasses import dataclass, field
import random
import re
import socket
from typing import Any
@@ -20,6 +21,7 @@ class SnmpCredential:
class DiscoveredSnmpInterface:
index: int
name: str
label: str
description: str | None
admin_status: str | None
oper_status: str | None
@@ -48,6 +50,13 @@ class DiscoveredSnmpDevice:
health_items: list[DiscoveredSnmpHealthItem] = field(default_factory=list)
@dataclass(frozen=True)
class InterfaceIdentity:
label: str
name: str
description: str | None
SYS_DESCR = (1, 3, 6, 1, 2, 1, 1, 1, 0)
SYS_UPTIME = (1, 3, 6, 1, 2, 1, 1, 3, 0)
SYS_NAME = (1, 3, 6, 1, 2, 1, 1, 5, 0)
@@ -69,6 +78,11 @@ ENT_PHY_SENSOR_SCALE = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 2)
ENT_PHY_SENSOR_PRECISION = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 3)
ENT_PHY_SENSOR_VALUE = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 4)
ENT_PHY_SENSOR_OPER_STATUS = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 5)
UCD_LA_LOAD_INT = (1, 3, 6, 1, 4, 1, 2021, 10, 1, 5)
UCD_MEM_TOTAL_REAL = (1, 3, 6, 1, 4, 1, 2021, 4, 5, 0)
UCD_MEM_AVAIL_REAL = (1, 3, 6, 1, 4, 1, 2021, 4, 6, 0)
UCD_DSK_PATH = (1, 3, 6, 1, 4, 1, 2021, 9, 1, 2)
UCD_DSK_PERCENT = (1, 3, 6, 1, 4, 1, 2021, 9, 1, 9)
HR_STORAGE_RAM = "1.3.6.1.2.1.25.2.1.2"
HR_STORAGE_VIRTUAL_MEMORY = "1.3.6.1.2.1.25.2.1.3"
@@ -104,27 +118,36 @@ class SnmpProfile:
key: str
name: str
match_terms: tuple[str, ...] = ()
health_source: str = "standard"
include_interfaces: bool = True
def matches(self, system_text: str) -> bool:
return any(term in system_text for term in self.match_terms)
def discover_health_items(self, client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
if self.health_source == "linux":
linux_items = _discover_linux_server_items(client)
host_items = _discover_host_resource_items(client)
if any(item.item_type == "linux_memory_usage" for item in linux_items):
host_items = [item for item in host_items if item.item_type != "memory_usage"]
return _deduplicate_items([*linux_items, *host_items, *_discover_sensor_items(client)])
return [*_discover_host_resource_items(client), *_discover_sensor_items(client)]
SNMP_PROFILES = (
SnmpProfile("cisco_ios", "Cisco IOS SNMP", ("cisco", "ios")),
SnmpProfile("mikrotik_routeros", "MikroTik RouterOS SNMP", ("mikrotik", "routeros")),
SnmpProfile("net_snmp", "Net-SNMP Host Resources", ("net-snmp", "linux")),
SnmpProfile("proxmox_ve", "Proxmox VE Server", ("proxmox", "-pve", " pve "), health_source="linux"),
SnmpProfile("linux_server", "Linux Server", ("linux", "net-snmp"), health_source="linux"),
SnmpProfile("generic_snmp", "Generic SNMP"),
)
def discover_snmp_device(host: str, credential: SnmpCredential) -> DiscoveredSnmpDevice:
def discover_snmp_device(host: str, credential: SnmpCredential, asset_type: str | None = None) -> DiscoveredSnmpDevice:
client = SnmpV2Client(host, credential)
system = client.get_many([SYS_NAME, SYS_DESCR, SYS_UPTIME])
profile = _select_profile(system)
interfaces = _discover_interfaces(client)
profile = _select_profile(system, asset_type)
interfaces = _discover_interfaces(client) if profile.include_interfaces else []
health_items = profile.discover_health_items(client)
return DiscoveredSnmpDevice(
host=host,
@@ -139,18 +162,36 @@ def discover_snmp_device(host: str, credential: SnmpCredential) -> DiscoveredSnm
)
def _select_profile(system: dict[tuple[int, ...], Any]) -> SnmpProfile:
def _select_profile(system: dict[tuple[int, ...], Any], asset_type: str | None = None) -> SnmpProfile:
system_text = " ".join(
value.lower()
for value in [_string_value(system.get(SYS_NAME)), _string_value(system.get(SYS_DESCR))]
if value
)
normalized_asset_type = (asset_type or "").strip().lower()
if normalized_asset_type == "server":
proxmox_profile = _profile_by_key("proxmox_ve")
if proxmox_profile.matches(system_text):
return proxmox_profile
return _profile_by_key("linux_server")
if normalized_asset_type == "network_device":
for profile in SNMP_PROFILES:
if profile.include_interfaces and profile.match_terms and profile.matches(system_text):
return profile
return _profile_by_key("generic_snmp")
for profile in SNMP_PROFILES:
if profile.match_terms and profile.matches(system_text):
return profile
return SNMP_PROFILES[-1]
def _profile_by_key(key: str) -> SnmpProfile:
for profile in SNMP_PROFILES:
if profile.key == key:
return profile
raise KeyError(key)
def _capabilities(
system: dict[tuple[int, ...], Any],
interfaces: list[DiscoveredSnmpInterface],
@@ -163,9 +204,9 @@ def _capabilities(
"interface_status": bool(interfaces),
"interface_traffic": bool(interfaces),
"interface_errors": bool(interfaces),
"cpu": "cpu_load" in item_types,
"memory": "memory_usage" in item_types,
"storage": "storage_usage" in item_types,
"cpu": bool({"cpu_load", "linux_load_average"} & item_types),
"memory": bool({"memory_usage", "linux_memory_usage"} & item_types),
"storage": bool({"storage_usage", "linux_disk_usage"} & item_types),
"sensors": "sensor_value" in item_types,
"environmental": "sensor_value" in item_types,
}
@@ -195,12 +236,13 @@ def _discover_interfaces(client: "SnmpV2Client") -> list[DiscoveredSnmpInterface
interfaces: list[DiscoveredSnmpInterface] = []
for index in indexes:
name = _string_value(name_by_index.get(index)) or _string_value(description_by_index.get(index)) or f"Interface {index}"
identity = _interface_identity(index, name_by_index.get(index), description_by_index.get(index))
interfaces.append(
DiscoveredSnmpInterface(
index=index,
name=name,
description=_string_value(description_by_index.get(index)),
name=identity.name,
label=identity.label,
description=identity.description,
admin_status=_status_label(admin_by_index.get(index)),
oper_status=_status_label(oper_by_index.get(index)),
speed_bps=_int_value(speed_by_index.get(index)),
@@ -209,6 +251,86 @@ def _discover_interfaces(client: "SnmpV2Client") -> list[DiscoveredSnmpInterface
return interfaces
def _interface_identity(index: int, name_value: Any, description_value: Any) -> InterfaceIdentity:
raw_name = _string_value(name_value)
raw_description = _string_value(description_value)
name = raw_name or raw_description or f"Interface {index}"
description = raw_description if raw_description and raw_description != name else None
return InterfaceIdentity(
label=_friendly_interface_label(name, raw_description, index),
name=name,
description=description,
)
def _friendly_interface_label(name: str, description: str | None, index: int) -> str:
value = _clean_interface_text(name) or _clean_interface_text(description or "") or f"Interface {index}"
lower = value.lower()
if lower in {"lo", "lo0", "loopback", "loopback0"}:
return "Loopback"
if lower.startswith("vlan"):
suffix = _suffix_after_prefix(value, "vlan")
return f"VLAN {suffix}" if suffix else "VLAN"
if lower.startswith("bridge"):
suffix = _suffix_after_prefix(value, "bridge")
return f"Bridge {suffix}" if suffix else "Bridge"
if lower.startswith("br-"):
return f"Bridge {value[3:]}"
if lower.startswith("bond"):
suffix = _suffix_after_prefix(value, "bond")
return f"Bond {suffix}" if suffix else "Bond"
if lower.startswith("lag"):
suffix = _suffix_after_prefix(value, "lag")
return f"LAG {suffix}" if suffix else "LAG"
if lower.startswith("sfp-sfpplus"):
suffix = _suffix_after_prefix(value, "sfp-sfpplus")
return f"SFP+ {suffix}" if suffix else "SFP+"
if lower.startswith("sfpplus"):
suffix = _suffix_after_prefix(value, "sfpplus")
return f"SFP+ {suffix}" if suffix else "SFP+"
if lower.startswith("sfp"):
suffix = _suffix_after_prefix(value, "sfp")
return f"SFP {suffix}" if suffix else "SFP"
if lower.startswith("ethernet"):
suffix = _suffix_after_prefix(value, "ethernet")
return f"Ethernet {suffix}" if suffix else "Ethernet"
if lower.startswith("ether"):
suffix = _suffix_after_prefix(value, "ether")
return f"Port {suffix}" if suffix else "Ethernet port"
if lower.startswith("gi"):
suffix = _suffix_after_prefix(value, "gi")
return f"GigabitEthernet {suffix}" if suffix else "GigabitEthernet"
if lower.startswith("te"):
suffix = _suffix_after_prefix(value, "te")
return f"TenGigabitEthernet {suffix}" if suffix else "TenGigabitEthernet"
if lower.startswith("fa"):
suffix = _suffix_after_prefix(value, "fa")
return f"FastEthernet {suffix}" if suffix else "FastEthernet"
if lower.startswith("ge-"):
return f"GigabitEthernet {value[3:]}"
if lower.startswith("xe-"):
return f"TenGigabitEthernet {value[3:]}"
if lower.startswith("et-"):
return f"Ethernet {value[3:]}"
return value
def _clean_interface_text(value: str) -> str:
cleaned = " ".join(value.strip().split())
if not cleaned:
return ""
if cleaned.startswith("Interface "):
return cleaned
return cleaned
def _suffix_after_prefix(value: str, prefix: str) -> str:
suffix = value[len(prefix) :].strip(" -_./")
return re.sub(r"\s+", " ", suffix)
def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
items: list[DiscoveredSnmpHealthItem] = []
@@ -251,6 +373,8 @@ def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmp
)
)
elif storage_type in {HR_STORAGE_FIXED_DISK, HR_STORAGE_REMOVABLE_DISK}:
if not _is_monitorable_storage_path(description):
continue
items.append(
DiscoveredSnmpHealthItem(
item_id=f"storage.{index}.usage",
@@ -264,6 +388,52 @@ def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmp
return _deduplicate_items(items)
def _discover_linux_server_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
items: list[DiscoveredSnmpHealthItem] = []
load_values = _indexed_values(client.walk(UCD_LA_LOAD_INT, max_items=16))
for index, label in [(1, "Load average 1 minute"), (2, "Load average 5 minutes"), (3, "Load average 15 minutes")]:
if _int_value(load_values.get(index)) is not None:
items.append(
DiscoveredSnmpHealthItem(
item_id=f"linux.load.{index}",
item_type="linux_load_average",
group="Server Health",
label=label,
)
)
memory = client.get_many([UCD_MEM_TOTAL_REAL, UCD_MEM_AVAIL_REAL])
if _int_value(memory.get(UCD_MEM_TOTAL_REAL)) and _int_value(memory.get(UCD_MEM_AVAIL_REAL)) is not None:
items.append(
DiscoveredSnmpHealthItem(
item_id="linux.memory.real",
item_type="linux_memory_usage",
group="Server Health",
label="Memory used",
unit="%",
)
)
disk_paths = _indexed_values(client.walk(UCD_DSK_PATH, max_items=256))
disk_percent = _indexed_values(client.walk(UCD_DSK_PERCENT, max_items=256))
for index in sorted(disk_paths):
path = _string_value(disk_paths.get(index))
if not path or _int_value(disk_percent.get(index)) is None:
continue
items.append(
DiscoveredSnmpHealthItem(
item_id=f"linux.disk.{index}",
item_type="linux_disk_usage",
group="Storage",
label=_storage_usage_label(path),
unit="%",
)
)
return _deduplicate_items(items)
def _discover_sensor_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
sensor_types = _indexed_values(client.walk(ENT_PHY_SENSOR_TYPE, max_items=256))
sensor_values = _indexed_values(client.walk(ENT_PHY_SENSOR_VALUE, max_items=256))
@@ -299,6 +469,20 @@ def _storage_usage_label(description: str) -> str:
return f"{normalized} usage"
def _is_monitorable_storage_path(description: str) -> bool:
normalized = description.strip()
if not normalized.startswith("/"):
return True
ignored_exact = {"/dev", "/dev/shm", "/proc", "/run", "/sys", "/tmp"}
ignored_prefixes = (
"/dev/",
"/proc/",
"/run/",
"/sys/",
)
return normalized not in ignored_exact and not normalized.startswith(ignored_prefixes)
def _deduplicate_items(items: list[DiscoveredSnmpHealthItem]) -> list[DiscoveredSnmpHealthItem]:
seen: set[tuple[str, str]] = set()
deduplicated: list[DiscoveredSnmpHealthItem] = []