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) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-21 20:21:08 -05:00
parent 77e9b680ae
commit 655f1eadae
7 changed files with 412 additions and 28 deletions

View File

@@ -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-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-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 - [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 - [x] **MGMT-05**: Bulk add returns per-device results (success/failure with reason) and supports partial success
### Fleet UI ### Fleet UI
- [ ] **UI-01**: Fleet table shows SNMP devices alongside MikroTik devices with type icon, status, CPU, memory, uptime - [x] **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) - [x] **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) - [x] **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-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-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 - [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 - [ ] **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-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-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 - [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 ### Backward Compatibility
@@ -136,12 +136,12 @@
| MGMT-01 | Phase 19 | Complete | | MGMT-01 | Phase 19 | Complete |
| MGMT-02 | Phase 19 | Complete | | MGMT-02 | Phase 19 | Complete |
| MGMT-03 | Phase 19 | Complete | | MGMT-03 | Phase 19 | Complete |
| MGMT-04 | Phase 19 | Pending | | MGMT-04 | Phase 19 | Complete |
| MGMT-05 | Phase 19 | Complete | | MGMT-05 | Phase 19 | Complete |
| UI-01 | Phase 19 | Pending | | UI-01 | Phase 19 | Complete |
| UI-02 | Phase 19 | Pending | | UI-02 | Phase 19 | Complete |
| UI-03 | Phase 19 | Pending | | UI-03 | Phase 19 | Complete |
| UI-04 | Phase 19 | Pending | | UI-04 | Phase 19 | Complete |
| UI-05 | Phase 19 | Complete | | UI-05 | Phase 19 | Complete |
| UI-06 | Phase 19 | Complete | | UI-06 | Phase 19 | Complete |
| UI-07 | Phase 20 | Pending | | UI-07 | Phase 20 | Pending |
@@ -149,7 +149,7 @@
| DATA-02 | Phase 18 | Complete | | DATA-02 | Phase 18 | Complete |
| DATA-03 | Phase 18 | Complete | | DATA-03 | Phase 18 | Complete |
| DATA-04 | Phase 17 | Complete | | DATA-04 | Phase 17 | Complete |
| DATA-05 | Phase 19 | Pending | | DATA-05 | Phase 19 | Complete |
| COMPAT-01 | Phase 16 | Complete | | COMPAT-01 | Phase 16 | Complete |
| COMPAT-02 | Phase 16 | Complete | | COMPAT-02 | Phase 16 | Complete |
| COMPAT-03 | Phase 16 | Complete | | COMPAT-03 | Phase 16 | Complete |

View File

@@ -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 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 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) - [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 20: Custom Profile Builder + MIB Upload** - MIB file upload, OID tree browser, profile editor, test profile against live device
## Phase Details ## 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 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 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 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: Plans:
- [ ] 19-01-PLAN.md -- API client SNMP types + fleet table type icon + device type filter - [ ] 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 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) 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 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 ## Coverage
@@ -141,7 +146,7 @@ Plans:
| Credentials | CRED-04, CRED-05 | 16 | 2 | | 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 | | 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 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 | | Fleet UI | UI-07 | 20 | 1 |
| Metrics & Data | DATA-01, DATA-02, DATA-03 | 18 | 3 | | Metrics & Data | DATA-01, DATA-02, DATA-03 | 18 | 3 |
| Metrics & Data | DATA-04 | 17 | 1 | | 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 | | 17. Backend API + Subscriber Extension | 3/3 | Complete | 2026-03-22 |
| 18. SNMP Collector Core | 0/5 | Not started | - | | 18. SNMP Collector Core | 0/5 | Not started | - |
| 19. Fleet UI + Bulk Add | 0/4 | 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* *Roadmap created: 2026-03-21*

View File

@@ -2,14 +2,14 @@
gsd_state_version: 1.0 gsd_state_version: 1.0
milestone: v9.8 milestone: v9.8
milestone_name: SNMP Device Integration milestone_name: SNMP Device Integration
status: unknown status: complete
stopped_at: Completed 19-02-PLAN.md (Add Device dialog + Bulk Add) stopped_at: Completed 19-04-PLAN.md (Device detail + SNMP metrics)
last_updated: "2026-03-22T01:01:06.013Z" last_updated: "2026-03-22T01:06:42.466Z"
progress: progress:
total_phases: 5 total_phases: 5
completed_phases: 3 completed_phases: 4
total_plans: 16 total_plans: 16
completed_plans: 15 completed_plans: 16
--- ---
# Project State # Project State
@@ -23,8 +23,8 @@ See: .planning/PROJECT.md (updated 2026-03-21)
## Current Position ## Current Position
Phase: 19 (Fleet UI + Bulk Add) — EXECUTING Phase: 19 (Fleet UI + Bulk Add) — COMPLETE
Plan: 4 of 4 Plan: 4 of 4 (all plans complete)
## Performance Metrics ## 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]: 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]: 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]: 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 ### Pending Todos
@@ -91,6 +94,6 @@ None yet.
## Session Continuity ## Session Continuity
Last session: 2026-03-22T01:00:58.297Z Last session: 2026-03-22T01:06:42.462Z
Stopped at: Completed 19-02-PLAN.md (Add Device dialog + Bulk Add) Stopped at: Completed 19-04-PLAN.md (Device detail + SNMP metrics)
Resume file: None Resume file: None

View File

@@ -139,6 +139,9 @@ class Settings(BaseSettings):
# Commercial license required above this limit. # Commercial license required above this limit.
LICENSE_DEVICES: int = 250 LICENSE_DEVICES: int = 250
# MIB parser binary path (tod-mib-parser Go binary)
MIB_PARSER_PATH: str = "/app/tod-mib-parser"
# App settings # App settings
APP_NAME: str = "TOD - The Other Dude" APP_NAME: str = "TOD - The Other Dude"
APP_VERSION: str = "9.7.2" APP_VERSION: str = "9.7.2"

View File

@@ -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 System-shipped profiles (is_system=True, tenant_id IS NULL) are visible to
all tenants but cannot be modified or deleted. 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: RBAC:
- devices:read scope: GET (list, detail) - 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) - tenant_admin+: DELETE (delete tenant profiles)
""" """
import json
import logging
import shutil
import subprocess
import tempfile
import uuid import uuid
from typing import Any 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 import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db 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.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.middleware.tenant_context import CurrentUser, get_current_user
from app.routers.devices import _check_tenant_access from app.routers.devices import _check_tenant_access
from app.schemas.snmp_profile import ( from app.schemas.snmp_profile import (
MIBParseResponse,
ProfileTestRequest,
ProfileTestResponse,
SNMPProfileCreate, SNMPProfileCreate,
SNMPProfileDetailResponse, SNMPProfileDetailResponse,
SNMPProfileListResponse, SNMPProfileListResponse,
SNMPProfileResponse, SNMPProfileResponse,
SNMPProfileUpdate, SNMPProfileUpdate,
) )
from app.services import snmp_proxy
logger = logging.getLogger(__name__)
router = APIRouter(tags=["snmp-profiles"]) 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) # List profiles (system + tenant)
@@ -303,3 +322,179 @@ async def delete_profile(
{"profile_id": str(profile_id), "tenant_id": str(tenant_id)}, {"profile_id": str(profile_id), "tenant_id": str(tenant_id)},
) )
await db.commit() 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,
)

View File

@@ -91,3 +91,69 @@ class SNMPProfileListResponse(BaseModel):
"""List of SNMP profiles.""" """List of SNMP profiles."""
profiles: list[SNMPProfileResponse] 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

View File

@@ -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")