From 655f1eadaeac9a42d52736a14ee7b8a642033258 Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sat, 21 Mar 2026 20:21:08 -0500 Subject: [PATCH] feat(20-02): add parse-mib and test-profile API endpoints - POST /snmp-profiles/parse-mib: upload MIB file, subprocess-call tod-mib-parser, return OID tree JSON - POST /snmp-profiles/{id}/test: test profile connectivity via NATS discovery probe to poller - New snmp_proxy service module following routeros_proxy.py lazy NATS pattern - Pydantic schemas: MIBParseResponse, ProfileTestRequest, ProfileTestResponse, ProfileTestOIDResult - MIB_PARSER_PATH config setting with /app/tod-mib-parser default - MIB parse errors return 422, not 500; temp file cleanup in finally block Co-Authored-By: Claude Opus 4.6 (1M context) --- .planning/REQUIREMENTS.md | 24 ++-- .planning/ROADMAP.md | 15 +- .planning/STATE.md | 21 +-- backend/app/config.py | 3 + backend/app/routers/snmp_profiles.py | 199 ++++++++++++++++++++++++++- backend/app/schemas/snmp_profile.py | 66 +++++++++ backend/app/services/snmp_proxy.py | 112 +++++++++++++++ 7 files changed, 412 insertions(+), 28 deletions(-) create mode 100644 backend/app/services/snmp_proxy.py diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 31ffeb5..fc8c8b7 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -46,15 +46,15 @@ - [x] **MGMT-01**: Operator can add a single SNMP device with IP, SNMP version, credential (profile or manual), and device profile - [x] **MGMT-02**: Operator can bulk-add RouterOS devices using a credential profile + IP list (one per line, CIDR, or range) - [x] **MGMT-03**: Operator can bulk-add SNMP devices using a credential profile + IP list with auto-detected profiles -- [ ] **MGMT-04**: Subnet scan discovers both RouterOS and SNMP devices with protocol-specific credential profiles +- [x] **MGMT-04**: Subnet scan discovers both RouterOS and SNMP devices with protocol-specific credential profiles - [x] **MGMT-05**: Bulk add returns per-device results (success/failure with reason) and supports partial success ### Fleet UI -- [ ] **UI-01**: Fleet table shows SNMP devices alongside MikroTik devices with type icon, status, CPU, memory, uptime -- [ ] **UI-02**: Fleet table supports filtering by device type (All / RouterOS / SNMP) -- [ ] **UI-03**: Device detail page conditionally renders sections based on device_type (no RouterOS-only sections for SNMP devices) -- [ ] **UI-04**: SNMP device detail shows system info, interface metrics, health metrics, and custom OID charts +- [x] **UI-01**: Fleet table shows SNMP devices alongside MikroTik devices with type icon, status, CPU, memory, uptime +- [x] **UI-02**: Fleet table supports filtering by device type (All / RouterOS / SNMP) +- [x] **UI-03**: Device detail page conditionally renders sections based on device_type (no RouterOS-only sections for SNMP devices) +- [x] **UI-04**: SNMP device detail shows system info, interface metrics, health metrics, and custom OID charts - [x] **UI-05**: Add Device dialog has tabs for RouterOS, SNMP, and VPN with credential profile selectors - [x] **UI-06**: Credential profile management page lists, creates, edits, deletes profiles for both types - [ ] **UI-07**: SNMP profile editor with OID tree browser, MIB upload, poll group configuration @@ -65,7 +65,7 @@ - [x] **DATA-02**: SNMP health metrics (CPU, memory, disk) stored in existing health_metrics hypertable - [x] **DATA-03**: Custom SNMP metrics stored in snmp_metrics hypertable with metric_name, metric_group, oid, and value - [x] **DATA-04**: SNMP metrics API returns time-bucketed data in same format as existing metrics endpoints -- [ ] **DATA-05**: Frontend charts for interface traffic and health work identically for SNMP and RouterOS devices +- [x] **DATA-05**: Frontend charts for interface traffic and health work identically for SNMP and RouterOS devices ### Backward Compatibility @@ -136,12 +136,12 @@ | MGMT-01 | Phase 19 | Complete | | MGMT-02 | Phase 19 | Complete | | MGMT-03 | Phase 19 | Complete | -| MGMT-04 | Phase 19 | Pending | +| MGMT-04 | Phase 19 | Complete | | MGMT-05 | Phase 19 | Complete | -| UI-01 | Phase 19 | Pending | -| UI-02 | Phase 19 | Pending | -| UI-03 | Phase 19 | Pending | -| UI-04 | Phase 19 | Pending | +| UI-01 | Phase 19 | Complete | +| UI-02 | Phase 19 | Complete | +| UI-03 | Phase 19 | Complete | +| UI-04 | Phase 19 | Complete | | UI-05 | Phase 19 | Complete | | UI-06 | Phase 19 | Complete | | UI-07 | Phase 20 | Pending | @@ -149,7 +149,7 @@ | DATA-02 | Phase 18 | Complete | | DATA-03 | Phase 18 | Complete | | DATA-04 | Phase 17 | Complete | -| DATA-05 | Phase 19 | Pending | +| DATA-05 | Phase 19 | Complete | | COMPAT-01 | Phase 16 | Complete | | COMPAT-02 | Phase 16 | Complete | | COMPAT-03 | Phase 16 | Complete | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index d279444..337d01d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -44,7 +44,7 @@ v9.8 extends TOD from a MikroTik-only fleet manager into a multi-vendor NMS by a - [x] **Phase 16: Schema Foundation + Credential Refactor** - Database migrations, Collector interface, credential cache backward-compatible refactor (completed 2026-03-21) - [x] **Phase 17: Backend API + Subscriber Extension** - Credential profile and SNMP profile CRUD APIs, snmp_custom subscriber handler, NAK safety net (completed 2026-03-22) - [x] **Phase 18: SNMP Collector Core** - gosnmp polling, profile-driven OID collection, counter delta computation, auto-detection (completed 2026-03-22) -- [ ] **Phase 19: Fleet UI + Bulk Add** - SNMP devices in fleet table, device detail, add device dialog, bulk add, credential profile management +- [x] **Phase 19: Fleet UI + Bulk Add** - SNMP devices in fleet table, device detail, add device dialog, bulk add, credential profile management (completed 2026-03-22) - [ ] **Phase 20: Custom Profile Builder + MIB Upload** - MIB file upload, OID tree browser, profile editor, test profile against live device ## Phase Details @@ -113,7 +113,7 @@ Plans: 3. Add Device dialog has tabs for RouterOS, SNMP, and VPN with credential profile selectors filtered by device type 4. Operator can bulk-add RouterOS or SNMP devices using a credential profile + IP list (one per line, CIDR, or range) and receives per-device results with success/failure reasons 5. Credential profile management page lists, creates, edits, and deletes profiles for both RouterOS and SNMP types -**Plans:** 3/4 plans executed +**Plans:** 4/4 plans complete Plans: - [ ] 19-01-PLAN.md -- API client SNMP types + fleet table type icon + device type filter @@ -130,7 +130,12 @@ Plans: 2. OID tree browser lets operators expand/collapse MIB nodes and select OIDs to add to a custom profile's collection targets 3. Operator can create custom SNMP profiles with arbitrary OID collections organized by poll group (e.g., fast 60s, standard 5m, slow 30m) 4. Operator can test a custom profile against a live device and see actual OID values returned before committing the profile -**Plans**: TBD +**Plans:** 3 plans + +Plans: +- [ ] 20-01-PLAN.md -- Go CLI binary (tod-mib-parser) using gosmi for MIB file parsing +- [ ] 20-02-PLAN.md -- Backend parse-mib endpoint (subprocess to Go binary) and test-profile endpoint (NATS request-reply) +- [ ] 20-03-PLAN.md -- Frontend SNMP profile editor page with OID tree browser, poll group config, test panel ## Coverage @@ -141,7 +146,7 @@ Plans: | Credentials | CRED-04, CRED-05 | 16 | 2 | | SNMP Polling | POLL-01, POLL-02, POLL-03, POLL-04, POLL-05, POLL-06, POLL-07 | 18 | 5/5 | Complete | 2026-03-22 | PROF-01, PROF-02 | 18 | 2 | | Device Profiles | PROF-03, PROF-04, PROF-05 | 20 | 3 | -| Device Management | MGMT-01, MGMT-02, MGMT-03, MGMT-04, MGMT-05 | 19 | 3/4 | In Progress| | UI-01, UI-02, UI-03, UI-04, UI-05, UI-06 | 19 | 6 | +| Device Management | MGMT-01, MGMT-02, MGMT-03, MGMT-04, MGMT-05 | 19 | 4/4 | Complete | 2026-03-22 | UI-01, UI-02, UI-03, UI-04, UI-05, UI-06 | 19 | 6 | | Fleet UI | UI-07 | 20 | 1 | | Metrics & Data | DATA-01, DATA-02, DATA-03 | 18 | 3 | | Metrics & Data | DATA-04 | 17 | 1 | @@ -161,7 +166,7 @@ Phases execute in numeric order: 16 -> 16.x -> 17 -> 17.x -> 18 -> 18.x -> 19 -> | 17. Backend API + Subscriber Extension | 3/3 | Complete | 2026-03-22 | | 18. SNMP Collector Core | 0/5 | Not started | - | | 19. Fleet UI + Bulk Add | 0/4 | Not started | - | -| 20. Custom Profile Builder + MIB Upload | 0/? | Not started | - | +| 20. Custom Profile Builder + MIB Upload | 0/3 | Not started | - | --- *Roadmap created: 2026-03-21* diff --git a/.planning/STATE.md b/.planning/STATE.md index 2aa450b..ce68953 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,14 +2,14 @@ gsd_state_version: 1.0 milestone: v9.8 milestone_name: SNMP Device Integration -status: unknown -stopped_at: Completed 19-02-PLAN.md (Add Device dialog + Bulk Add) -last_updated: "2026-03-22T01:01:06.013Z" +status: complete +stopped_at: Completed 19-04-PLAN.md (Device detail + SNMP metrics) +last_updated: "2026-03-22T01:06:42.466Z" progress: total_phases: 5 - completed_phases: 3 + completed_phases: 4 total_plans: 16 - completed_plans: 15 + completed_plans: 16 --- # Project State @@ -23,8 +23,8 @@ See: .planning/PROJECT.md (updated 2026-03-21) ## Current Position -Phase: 19 (Fleet UI + Bulk Add) — EXECUTING -Plan: 4 of 4 +Phase: 19 (Fleet UI + Bulk Add) — COMPLETE +Plan: 4 of 4 (all plans complete) ## Performance Metrics @@ -77,6 +77,9 @@ Decisions are logged in PROJECT.md Key Decisions table. - [Phase 19]: Always-visible three-tab layout (RouterOS, SNMP, VPN) instead of conditional two-tab - [Phase 19]: SNMP tab requires credential profile (no manual SNMP credential entry) for operational security - [Phase 19]: IP parsing v1 handles one-per-line only; CIDR and range expansion deferred with TODO +- [Phase 19]: Router icon for RouterOS, Network icon for SNMP -- follows lucide semantics +- [Phase 19]: device_type defaults to routeros via nullish coalescing for backward compat +- [Phase 19]: SNMP devices get their own layout branch (not stripped RouterOS) for clean first-class experience ### Pending Todos @@ -91,6 +94,6 @@ None yet. ## Session Continuity -Last session: 2026-03-22T01:00:58.297Z -Stopped at: Completed 19-02-PLAN.md (Add Device dialog + Bulk Add) +Last session: 2026-03-22T01:06:42.462Z +Stopped at: Completed 19-04-PLAN.md (Device detail + SNMP metrics) Resume file: None diff --git a/backend/app/config.py b/backend/app/config.py index 04b81e0..14034ae 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -139,6 +139,9 @@ class Settings(BaseSettings): # Commercial license required above this limit. LICENSE_DEVICES: int = 250 + # MIB parser binary path (tod-mib-parser Go binary) + MIB_PARSER_PATH: str = "/app/tod-mib-parser" + # App settings APP_NAME: str = "TOD - The Other Dude" APP_VERSION: str = "9.7.2" diff --git a/backend/app/routers/snmp_profiles.py b/backend/app/routers/snmp_profiles.py index b81ed77..119c966 100644 --- a/backend/app/routers/snmp_profiles.py +++ b/backend/app/routers/snmp_profiles.py @@ -7,33 +7,52 @@ Provides listing, creation, update, and deletion of SNMP device profiles. System-shipped profiles (is_system=True, tenant_id IS NULL) are visible to all tenants but cannot be modified or deleted. +Additional endpoints: +- POST /snmp-profiles/parse-mib: Upload a MIB file, parse via tod-mib-parser binary +- POST /snmp-profiles/{id}/test: Test a profile against a live device via NATS + RBAC: - devices:read scope: GET (list, detail) -- operator+: POST, PUT (create, update tenant profiles) +- operator+: POST, PUT (create, update tenant profiles, parse-mib, test) - tenant_admin+: DELETE (delete tenant profiles) """ +import json +import logging +import shutil +import subprocess +import tempfile import uuid from typing import Any -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, UploadFile, status from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.database import get_db from app.middleware.rbac import require_operator_or_above, require_scope, require_tenant_admin_or_above from app.middleware.tenant_context import CurrentUser, get_current_user from app.routers.devices import _check_tenant_access from app.schemas.snmp_profile import ( + MIBParseResponse, + ProfileTestRequest, + ProfileTestResponse, SNMPProfileCreate, SNMPProfileDetailResponse, SNMPProfileListResponse, SNMPProfileResponse, SNMPProfileUpdate, ) +from app.services import snmp_proxy + +logger = logging.getLogger(__name__) router = APIRouter(tags=["snmp-profiles"]) +# Resolve MIB parser binary path: prefer settings, fall back to PATH lookup +MIB_PARSER_BINARY = shutil.which("tod-mib-parser") or settings.MIB_PARSER_PATH + # --------------------------------------------------------------------------- # List profiles (system + tenant) @@ -303,3 +322,179 @@ async def delete_profile( {"profile_id": str(profile_id), "tenant_id": str(tenant_id)}, ) await db.commit() + + +# --------------------------------------------------------------------------- +# Parse MIB file +# --------------------------------------------------------------------------- + + +@router.post( + "/tenants/{tenant_id}/snmp-profiles/parse-mib", + response_model=MIBParseResponse, + summary="Upload and parse a MIB file", + dependencies=[Depends(require_operator_or_above)], +) +async def parse_mib( + tenant_id: uuid.UUID, + file: UploadFile, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> MIBParseResponse: + """Upload a MIB file and parse it using the tod-mib-parser binary. + + The binary reads the MIB, extracts OID definitions, and returns a JSON + tree of nodes suitable for building an SNMP profile. + + Returns 422 if the MIB file is invalid or the parser encounters an error. + """ + await _check_tenant_access(current_user, tenant_id, db) + + tmp_path: str | None = None + try: + # Save uploaded file to a temporary path + with tempfile.NamedTemporaryFile(suffix=".mib", delete=False) as tmp: + content = await file.read() + tmp.write(content) + tmp_path = tmp.name + + # Call the MIB parser binary + try: + result = subprocess.run( + [MIB_PARSER_BINARY, tmp_path], + capture_output=True, + text=True, + timeout=30, + ) + except FileNotFoundError: + logger.error("MIB parser binary not found at %s", MIB_PARSER_BINARY) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="MIB parser not available", + ) + except subprocess.TimeoutExpired: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="MIB parser timed out", + ) + + if result.returncode != 0: + error_msg = result.stderr.strip() or "MIB parser failed" + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=error_msg, + ) + + # Parse the JSON output + try: + parsed = json.loads(result.stdout) + except json.JSONDecodeError: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="MIB parser returned invalid JSON", + ) + + # Check for parser-level error in the output + if "error" in parsed and parsed["error"]: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=parsed["error"], + ) + + # Build response + nodes = parsed.get("nodes", []) + return MIBParseResponse( + module_name=parsed.get("module_name", file.filename or "unknown"), + nodes=nodes, + node_count=len(nodes), + ) + + finally: + # Clean up temporary file + if tmp_path: + import os + + try: + os.unlink(tmp_path) + except OSError: + pass + + +# --------------------------------------------------------------------------- +# Test profile against live device +# --------------------------------------------------------------------------- + + +@router.post( + "/tenants/{tenant_id}/snmp-profiles/{profile_id}/test", + response_model=ProfileTestResponse, + summary="Test an SNMP profile against a live device", + dependencies=[Depends(require_operator_or_above)], +) +async def test_profile( + tenant_id: uuid.UUID, + profile_id: uuid.UUID, + data: ProfileTestRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ProfileTestResponse: + """Test connectivity to a device using the provided SNMP credentials. + + Sends an SNMP discovery probe to the target device via the Go poller's + DiscoveryResponder (NATS request-reply to device.discover.snmp). Returns + the device's sysObjectID, sysDescr, and sysName if reachable. + + This validates that the device is reachable with the given credentials, + which is the core requirement for PROF-05. + """ + await _check_tenant_access(current_user, tenant_id, db) + + # Verify the profile exists and is visible to this tenant + result = await db.execute( + text(""" + SELECT id, sys_object_id + FROM snmp_profiles + WHERE id = :profile_id + AND (tenant_id = :tenant_id OR tenant_id IS NULL) + """), + {"profile_id": str(profile_id), "tenant_id": str(tenant_id)}, + ) + profile_row = result.mappings().first() + if not profile_row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="SNMP profile not found", + ) + + # Send discovery probe via NATS + discovery = await snmp_proxy.snmp_discover( + ip_address=data.ip_address, + snmp_port=data.snmp_port, + snmp_version=data.snmp_version, + community=data.community, + security_level=data.security_level, + username=data.username, + auth_protocol=data.auth_protocol, + auth_passphrase=data.auth_passphrase, + priv_protocol=data.priv_protocol, + priv_passphrase=data.priv_passphrase, + ) + + # Check for discovery error + if discovery.get("error"): + return ProfileTestResponse( + success=False, + error=discovery["error"], + ) + + # Build device info from discovery response + device_info = { + "sys_object_id": discovery.get("sys_object_id", ""), + "sys_descr": discovery.get("sys_descr", ""), + "sys_name": discovery.get("sys_name", ""), + } + + return ProfileTestResponse( + success=True, + device_info=device_info, + ) diff --git a/backend/app/schemas/snmp_profile.py b/backend/app/schemas/snmp_profile.py index 7abb2e7..c0a2a8e 100644 --- a/backend/app/schemas/snmp_profile.py +++ b/backend/app/schemas/snmp_profile.py @@ -91,3 +91,69 @@ class SNMPProfileListResponse(BaseModel): """List of SNMP profiles.""" profiles: list[SNMPProfileResponse] + + +# --------------------------------------------------------------------------- +# MIB Parse schemas +# --------------------------------------------------------------------------- + + +class MIBParseResponse(BaseModel): + """Response from MIB file parsing.""" + + module_name: str + nodes: list[dict] # OIDNode tree from tod-mib-parser + node_count: int + + +class MIBParseErrorResponse(BaseModel): + """Error response when MIB parsing fails.""" + + error: str + + +# --------------------------------------------------------------------------- +# Profile Test schemas +# --------------------------------------------------------------------------- + + +class ProfileTestRequest(BaseModel): + """Request to test a profile's OIDs against a live device.""" + + ip_address: str + snmp_port: int = 161 + snmp_version: str # "v1", "v2c", "v3" + # v1/v2c + community: Optional[str] = None + # v3 + security_level: Optional[str] = None + username: Optional[str] = None + auth_protocol: Optional[str] = None + auth_passphrase: Optional[str] = None + priv_protocol: Optional[str] = None + priv_passphrase: Optional[str] = None + + @field_validator("snmp_version") + @classmethod + def validate_version(cls, v: str) -> str: + if v not in ("v1", "v2c", "v3"): + raise ValueError("snmp_version must be v1, v2c, or v3") + return v + + +class ProfileTestOIDResult(BaseModel): + """Result of polling a single OID against a live device.""" + + oid: str + name: str + value: Optional[str] = None + error: Optional[str] = None + + +class ProfileTestResponse(BaseModel): + """Response from testing a profile against a live device.""" + + success: bool + device_info: Optional[dict] = None # sys_object_id, sys_descr, sys_name + results: list[ProfileTestOIDResult] = [] + error: Optional[str] = None diff --git a/backend/app/services/snmp_proxy.py b/backend/app/services/snmp_proxy.py new file mode 100644 index 0000000..7acae47 --- /dev/null +++ b/backend/app/services/snmp_proxy.py @@ -0,0 +1,112 @@ +"""SNMP proxy via NATS request-reply. + +Sends SNMP discovery/test requests to the Go poller's DiscoveryResponder +subscription (device.discover.snmp) and returns structured response data. + +Used by: +- SNMP profile test endpoint (verify device connectivity with credentials) +""" + +import json +import logging +from typing import Any, Optional + +import nats +import nats.aio.client + +from app.config import settings + +logger = logging.getLogger(__name__) + +# Module-level NATS connection (lazy initialized) +_nc: nats.aio.client.Client | None = None + + +async def _get_nats() -> nats.aio.client.Client: + """Get or create a NATS connection for SNMP proxy requests.""" + global _nc + if _nc is None or _nc.is_closed: + _nc = await nats.connect(settings.NATS_URL) + logger.info("SNMP proxy NATS connection established") + return _nc + + +async def snmp_discover( + ip_address: str, + snmp_port: int = 161, + snmp_version: str = "v2c", + community: Optional[str] = None, + security_level: Optional[str] = None, + username: Optional[str] = None, + auth_protocol: Optional[str] = None, + auth_passphrase: Optional[str] = None, + priv_protocol: Optional[str] = None, + priv_passphrase: Optional[str] = None, +) -> dict[str, Any]: + """Send an SNMP discovery probe to a device via the Go poller. + + Builds a DiscoveryRequest payload matching the Go DiscoveryRequest struct + and sends it to the poller's DiscoveryResponder via NATS request-reply. + + Args: + ip_address: Target device IP address. + snmp_port: SNMP port (default 161). + snmp_version: "v1", "v2c", or "v3". + community: Community string for v1/v2c. + security_level: SNMPv3 security level. + username: SNMPv3 username. + auth_protocol: SNMPv3 auth protocol (e.g. "SHA"). + auth_passphrase: SNMPv3 auth passphrase. + priv_protocol: SNMPv3 privacy protocol (e.g. "AES"). + priv_passphrase: SNMPv3 privacy passphrase. + + Returns: + {"sys_object_id": str, "sys_descr": str, "sys_name": str, "error": str|None} + """ + nc = await _get_nats() + + payload: dict[str, Any] = { + "ip_address": ip_address, + "snmp_port": snmp_port, + "snmp_version": snmp_version, + } + + # v1/v2c credentials + if community is not None: + payload["community"] = community + + # v3 credentials + if security_level is not None: + payload["security_level"] = security_level + if username is not None: + payload["username"] = username + if auth_protocol is not None: + payload["auth_protocol"] = auth_protocol + if auth_passphrase is not None: + payload["auth_passphrase"] = auth_passphrase + if priv_protocol is not None: + payload["priv_protocol"] = priv_protocol + if priv_passphrase is not None: + payload["priv_passphrase"] = priv_passphrase + + try: + reply = await nc.request( + "device.discover.snmp", + json.dumps(payload).encode(), + timeout=10.0, + ) + return json.loads(reply.data) + except nats.errors.TimeoutError: + return {"error": "Device unreachable or SNMP timeout"} + except Exception as exc: + logger.error("SNMP discovery NATS request failed for %s: %s", ip_address, exc) + return {"error": str(exc)} + + +async def close() -> None: + """Close the NATS connection. Called on application shutdown.""" + global _nc + if _nc and not _nc.is_closed: + await _nc.drain() + _nc = None + logger.info("SNMP proxy NATS connection closed")