Add SNMP profile mapping and fix asset cleanup

This commit is contained in:
Keith Smith
2026-05-26 16:34:10 -06:00
parent fe7157fdad
commit e59733d331
15 changed files with 676 additions and 35 deletions
+24 -1
View File
@@ -1,10 +1,12 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.db.session import get_db
from app.models import Asset, User
from app.models import Asset, Incident, Monitor, User
from app.schemas.core import AssetCreate, AssetRead, AssetUpdate
router = APIRouter(prefix="/assets", tags=["assets"])
@@ -86,5 +88,26 @@ def delete_asset(
asset = db.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
attached_monitors = db.scalars(select(Monitor).where(Monitor.asset_id == asset.id)).all()
attached_monitor_ids = [monitor.id for monitor in attached_monitors]
now = datetime.now(UTC)
if attached_monitor_ids:
monitor_incidents = db.scalars(select(Incident).where(Incident.monitor_id.in_(attached_monitor_ids))).all()
for incident in monitor_incidents:
if incident.status == "open":
incident.status = "resolved"
incident.resolved_at = now
incident.details = {**(incident.details or {}), "recovery_message": "Asset was deleted"}
incident.monitor_id = None
asset_incidents = db.scalars(select(Incident).where(Incident.asset_id == asset.id)).all()
for incident in asset_incidents:
incident.asset_id = None
for monitor in attached_monitors:
db.delete(monitor)
db.delete(asset)
db.commit()
+23 -8
View File
@@ -65,6 +65,9 @@ def _discovery_to_read(credential_profile_id: int, discovered: DiscoveredSnmpDev
return SnmpDiscoveryRead(
host=discovered.host,
credential_profile_id=credential_profile_id,
profile_key=discovered.profile_key,
profile_name=discovered.profile_name,
capabilities=discovered.capabilities,
device_name=discovered.device_name,
description=discovered.description,
uptime_seconds=discovered.uptime_seconds,
@@ -74,15 +77,27 @@ def _discovery_to_read(credential_profile_id: int, discovered: DiscoveredSnmpDev
def _monitorable_items(discovered: DiscoveredSnmpDevice) -> list[SnmpDiscoveryItemRead]:
items = [
SnmpDiscoveryItemRead(
item_id="device.uptime",
item_type="device_uptime",
group="Device Health",
label="Device uptime",
unit="seconds",
items = []
if discovered.uptime_seconds is not None:
items.append(
SnmpDiscoveryItemRead(
item_id="device.uptime",
item_type="device_uptime",
group="Device Health",
label="Device uptime",
unit="seconds",
)
)
]
items.extend(
SnmpDiscoveryItemRead(
item_id=item.item_id,
item_type=item.item_type,
group=item.group,
label=item.label,
unit=item.unit,
)
for item in discovered.health_items
)
for interface in discovered.interfaces:
group = f"Interface {interface.name}"
item_prefix = f"interface.{interface.index}"
+3
View File
@@ -248,6 +248,9 @@ class SnmpMonitorsCreate(BaseModel):
class SnmpDiscoveryRead(BaseModel):
host: str
credential_profile_id: int
profile_key: str
profile_name: str
capabilities: dict[str, bool]
device_name: str | None
description: str | None
uptime_seconds: int | None
+209 -2
View File
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
import random
import socket
from typing import Any
@@ -26,6 +26,15 @@ class DiscoveredSnmpInterface:
speed_bps: int | None
@dataclass(frozen=True)
class DiscoveredSnmpHealthItem:
item_id: str
item_type: str
group: str
label: str
unit: str | None = None
@dataclass(frozen=True)
class DiscoveredSnmpDevice:
host: str
@@ -33,6 +42,10 @@ class DiscoveredSnmpDevice:
description: str | None
uptime_seconds: int | None
interfaces: list[DiscoveredSnmpInterface]
profile_key: str = "generic_snmp"
profile_name: str = "Generic SNMP"
capabilities: dict[str, bool] = field(default_factory=dict)
health_items: list[DiscoveredSnmpHealthItem] = field(default_factory=list)
SYS_DESCR = (1, 3, 6, 1, 2, 1, 1, 1, 0)
@@ -43,6 +56,24 @@ IF_SPEED = (1, 3, 6, 1, 2, 1, 2, 2, 1, 5)
IF_ADMIN_STATUS = (1, 3, 6, 1, 2, 1, 2, 2, 1, 7)
IF_OPER_STATUS = (1, 3, 6, 1, 2, 1, 2, 2, 1, 8)
IF_NAME = (1, 3, 6, 1, 2, 1, 31, 1, 1, 1, 1)
HR_PROCESSOR_LOAD = (1, 3, 6, 1, 2, 1, 25, 3, 3, 1, 2)
HR_STORAGE_TYPE = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 2)
HR_STORAGE_DESCR = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 3)
HR_STORAGE_ALLOCATION_UNITS = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 4)
HR_STORAGE_SIZE = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 5)
HR_STORAGE_USED = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 6)
ENT_PHYSICAL_DESCR = (1, 3, 6, 1, 2, 1, 47, 1, 1, 1, 1, 2)
ENT_PHYSICAL_NAME = (1, 3, 6, 1, 2, 1, 47, 1, 1, 1, 1, 7)
ENT_PHY_SENSOR_TYPE = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 1)
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)
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"
HR_STORAGE_FIXED_DISK = "1.3.6.1.2.1.25.2.1.4"
HR_STORAGE_REMOVABLE_DISK = "1.3.6.1.2.1.25.2.1.5"
STATUS_LABELS = {
1: "up",
@@ -54,20 +85,92 @@ STATUS_LABELS = {
7: "lower layer down",
}
SENSOR_TYPE_LABELS = {
3: ("AC voltage", "V"),
4: ("DC voltage", "V"),
5: ("Current", "A"),
6: ("Power", "W"),
7: ("Frequency", "Hz"),
8: ("Temperature", "C"),
9: ("Humidity", "%"),
10: ("Fan speed", "rpm"),
11: ("Airflow", "m3/min"),
12: ("Sensor state", None),
}
@dataclass(frozen=True)
class SnmpProfile:
key: str
name: str
match_terms: tuple[str, ...] = ()
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]:
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("generic_snmp", "Generic SNMP"),
)
def discover_snmp_device(host: str, credential: SnmpCredential) -> DiscoveredSnmpDevice:
client = SnmpV2Client(host, credential)
system = client.get_many([SYS_NAME, SYS_DESCR, SYS_UPTIME])
profile = _select_profile(system)
interfaces = _discover_interfaces(client)
health_items = profile.discover_health_items(client)
return DiscoveredSnmpDevice(
host=host,
device_name=_string_value(system.get(SYS_NAME)),
description=_string_value(system.get(SYS_DESCR)),
uptime_seconds=_timeticks_to_seconds(system.get(SYS_UPTIME)),
interfaces=interfaces,
profile_key=profile.key,
profile_name=profile.name,
capabilities=_capabilities(system, interfaces, health_items),
health_items=health_items,
)
def _select_profile(system: dict[tuple[int, ...], Any]) -> SnmpProfile:
system_text = " ".join(
value.lower()
for value in [_string_value(system.get(SYS_NAME)), _string_value(system.get(SYS_DESCR))]
if value
)
for profile in SNMP_PROFILES:
if profile.match_terms and profile.matches(system_text):
return profile
return SNMP_PROFILES[-1]
def _capabilities(
system: dict[tuple[int, ...], Any],
interfaces: list[DiscoveredSnmpInterface],
health_items: list[DiscoveredSnmpHealthItem],
) -> dict[str, bool]:
item_types = {item.item_type for item in health_items}
return {
"system": any(system.get(oid) is not None for oid in [SYS_NAME, SYS_DESCR, SYS_UPTIME]),
"interfaces": bool(interfaces),
"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,
"sensors": "sensor_value" in item_types,
"environmental": "sensor_value" in item_types,
}
def _discover_interfaces(client: "SnmpV2Client") -> list[DiscoveredSnmpInterface]:
names = client.walk(IF_NAME)
descriptions = client.walk(IF_DESCR)
@@ -106,6 +209,108 @@ def _discover_interfaces(client: "SnmpV2Client") -> list[DiscoveredSnmpInterface
return interfaces
def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
items: list[DiscoveredSnmpHealthItem] = []
processor_loads = _indexed_values(client.walk(HR_PROCESSOR_LOAD, max_items=256))
processor_indexes = sorted(index for index, value in processor_loads.items() if _int_value(value) is not None)
for position, index in enumerate(processor_indexes, start=1):
label = "CPU load" if len(processor_indexes) == 1 else f"CPU {position} load"
items.append(
DiscoveredSnmpHealthItem(
item_id=f"cpu.{index}.load",
item_type="cpu_load",
group="Device Health",
label=label,
unit="%",
)
)
storage_types = _indexed_values(client.walk(HR_STORAGE_TYPE, max_items=256))
descriptions = _indexed_values(client.walk(HR_STORAGE_DESCR, max_items=256))
allocation_units = _indexed_values(client.walk(HR_STORAGE_ALLOCATION_UNITS, max_items=256))
sizes = _indexed_values(client.walk(HR_STORAGE_SIZE, max_items=256))
used = _indexed_values(client.walk(HR_STORAGE_USED, max_items=256))
for index in sorted(storage_types):
storage_type = _string_value(storage_types.get(index))
allocation_unit = _int_value(allocation_units.get(index))
size = _int_value(sizes.get(index))
used_blocks = _int_value(used.get(index))
if not storage_type or not allocation_unit or not size or used_blocks is None:
continue
description = _string_value(descriptions.get(index)) or f"Storage {index}"
if storage_type in {HR_STORAGE_RAM, HR_STORAGE_VIRTUAL_MEMORY}:
items.append(
DiscoveredSnmpHealthItem(
item_id=f"storage.{index}.memory",
item_type="memory_usage",
group="Device Health",
label="Memory used",
unit="%",
)
)
elif storage_type in {HR_STORAGE_FIXED_DISK, HR_STORAGE_REMOVABLE_DISK}:
items.append(
DiscoveredSnmpHealthItem(
item_id=f"storage.{index}.usage",
item_type="storage_usage",
group="Storage",
label=_storage_usage_label(description),
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))
sensor_names = _indexed_values(client.walk(ENT_PHYSICAL_NAME, max_items=256))
sensor_descriptions = _indexed_values(client.walk(ENT_PHYSICAL_DESCR, max_items=256))
items: list[DiscoveredSnmpHealthItem] = []
for index in sorted(sensor_types):
sensor_type = _int_value(sensor_types.get(index))
if sensor_type not in SENSOR_TYPE_LABELS or _int_value(sensor_values.get(index)) is None:
continue
kind, unit = SENSOR_TYPE_LABELS[sensor_type]
name = _string_value(sensor_names.get(index)) or _string_value(sensor_descriptions.get(index))
label = kind if not name else f"{kind} {name}"
items.append(
DiscoveredSnmpHealthItem(
item_id=f"sensor.{index}.value",
item_type="sensor_value",
group="Environmental",
label=label,
unit=unit,
)
)
return items
def _storage_usage_label(description: str) -> str:
normalized = description.strip()
if normalized in {"/", "/boot", "/home", "/var"}:
return f"Disk {normalized} usage"
if "disk" not in normalized.lower() and normalized.startswith("/"):
return f"Disk {normalized} usage"
return f"{normalized} usage"
def _deduplicate_items(items: list[DiscoveredSnmpHealthItem]) -> list[DiscoveredSnmpHealthItem]:
seen: set[tuple[str, str]] = set()
deduplicated: list[DiscoveredSnmpHealthItem] = []
for item in items:
key = (item.item_type, item.label)
if key in seen:
continue
seen.add(key)
deduplicated.append(item)
return deduplicated
def _indexed_values(values: dict[tuple[int, ...], Any]) -> dict[int, Any]:
indexed: dict[int, Any] = {}
for oid, value in values.items():
@@ -317,7 +522,9 @@ def _decode_oid(value: bytes) -> tuple[int, ...]:
def _decode_value(tag: int, value: bytes) -> Any:
if tag in {0x02, 0x41, 0x42, 0x43, 0x46}:
if tag == 0x02:
return _decode_integer(value)
if tag in {0x41, 0x42, 0x43, 0x46}:
return int.from_bytes(value, "big")
if tag == 0x04:
return value.decode("utf-8", errors="replace")