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")
+84 -1
View File
@@ -2,7 +2,27 @@ from fastapi.testclient import TestClient
from app.core.secrets import encrypt_secret
from app.models import Credential
from app.services.snmp import DiscoveredSnmpDevice, DiscoveredSnmpInterface, SnmpCredential, SnmpDiscoveryError
from app.services.snmp import (
ENT_PHYSICAL_NAME,
ENT_PHY_SENSOR_TYPE,
ENT_PHY_SENSOR_VALUE,
HR_PROCESSOR_LOAD,
HR_STORAGE_ALLOCATION_UNITS,
HR_STORAGE_DESCR,
HR_STORAGE_FIXED_DISK,
HR_STORAGE_RAM,
HR_STORAGE_SIZE,
HR_STORAGE_TYPE,
HR_STORAGE_USED,
SYS_DESCR,
SYS_NAME,
SYS_UPTIME,
DiscoveredSnmpDevice,
DiscoveredSnmpInterface,
SnmpCredential,
SnmpDiscoveryError,
discover_snmp_device,
)
def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestClient, db_session, monkeypatch) -> None:
@@ -49,6 +69,9 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
body = response.json()
assert body["host"] == "192.0.2.10"
assert body["credential_profile_id"] == profile.id
assert body["profile_key"] == "generic_snmp"
assert body["profile_name"] == "Generic SNMP"
assert body["capabilities"] == {}
assert body["device_name"] == "core-sw-1"
assert body["description"] == "Core switch"
assert body["uptime_seconds"] == 12345
@@ -96,6 +119,66 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
assert "1.3.6" not in response.text
def test_snmp_profile_mapping_discovers_standard_health_items(monkeypatch) -> None:
class FakeClient:
def __init__(self, host: str, credential: SnmpCredential) -> None:
self.host = host
self.credential = credential
def get_many(self, _oids):
return {
SYS_NAME: "edge-router",
SYS_DESCR: "Linux edge-router net-snmp",
SYS_UPTIME: 10_000,
}
def walk(self, base_oid, max_items=128):
values = {
HR_PROCESSOR_LOAD: {(*HR_PROCESSOR_LOAD, 196608): 17},
HR_STORAGE_TYPE: {
(*HR_STORAGE_TYPE, 1): HR_STORAGE_RAM,
(*HR_STORAGE_TYPE, 31): HR_STORAGE_FIXED_DISK,
},
HR_STORAGE_DESCR: {
(*HR_STORAGE_DESCR, 1): "Physical memory",
(*HR_STORAGE_DESCR, 31): "/",
},
HR_STORAGE_ALLOCATION_UNITS: {
(*HR_STORAGE_ALLOCATION_UNITS, 1): 1024,
(*HR_STORAGE_ALLOCATION_UNITS, 31): 4096,
},
HR_STORAGE_SIZE: {
(*HR_STORAGE_SIZE, 1): 2048,
(*HR_STORAGE_SIZE, 31): 4096,
},
HR_STORAGE_USED: {
(*HR_STORAGE_USED, 1): 1024,
(*HR_STORAGE_USED, 31): 1024,
},
ENT_PHY_SENSOR_TYPE: {(*ENT_PHY_SENSOR_TYPE, 10): 8},
ENT_PHY_SENSOR_VALUE: {(*ENT_PHY_SENSOR_VALUE, 10): 310},
ENT_PHYSICAL_NAME: {(*ENT_PHYSICAL_NAME, 10): "Inlet"},
}
return values.get(base_oid, {})
monkeypatch.setattr("app.services.snmp.SnmpV2Client", FakeClient)
discovered = discover_snmp_device("192.0.2.20", SnmpCredential(community="private-community"))
assert discovered.profile_key == "net_snmp"
assert discovered.profile_name == "Net-SNMP Host Resources"
assert discovered.capabilities["cpu"] is True
assert discovered.capabilities["memory"] is True
assert discovered.capabilities["storage"] is True
assert discovered.capabilities["sensors"] is True
assert [(item.item_id, item.item_type, item.group, item.label, item.unit) for item in discovered.health_items] == [
("cpu.196608.load", "cpu_load", "Device Health", "CPU load", "%"),
("storage.1.memory", "memory_usage", "Device Health", "Memory used", "%"),
("storage.31.usage", "storage_usage", "Storage", "Disk / usage", "%"),
("sensor.10.value", "sensor_value", "Environmental", "Temperature Inlet", "C"),
]
def test_snmp_discovery_rejects_missing_profile(client: TestClient) -> None:
response = client.post("/discovery/snmp", json={"host": "192.0.2.10", "credential_profile_id": 999})
+39 -1
View File
@@ -3,7 +3,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.secrets import encrypt_secret
from app.models import AlertRule, Asset, Credential, Monitor
from app.models import AlertRule, Asset, Credential, Incident, Monitor
def test_create_website_monitor_creates_asset_and_alert_rule(client: TestClient, db_session: Session) -> None:
@@ -211,3 +211,41 @@ def test_create_snmp_monitors_rejects_missing_profile(client: TestClient, db_ses
)
assert response.status_code == 404
def test_delete_asset_deletes_attached_monitors_and_resolves_incidents(client: TestClient, db_session: Session) -> None:
asset = Asset(name="Router", asset_type="network_device", address="192.0.2.1", status="down", extra={})
monitor = Monitor(
asset=asset,
name="Router ping",
monitor_type="ping",
target="192.0.2.1",
config={},
interval_seconds=60,
status="down",
)
db_session.add_all([asset, monitor])
db_session.flush()
incident = Incident(
asset_id=asset.id,
monitor_id=monitor.id,
alert_rule_id=None,
title="Router ping is failing",
severity="warning",
status="open",
details={"last_message": "Ping failed"},
)
db_session.add(incident)
db_session.commit()
response = client.delete(f"/assets/{asset.id}")
assert response.status_code == 204
assert db_session.get(Asset, asset.id) is None
assert db_session.get(Monitor, monitor.id) is None
db_session.refresh(incident)
assert incident.status == "resolved"
assert incident.resolved_at is not None
assert incident.asset_id is None
assert incident.monitor_id is None
assert incident.details["recovery_message"] == "Asset was deleted"