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:
@@ -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 |
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
112
backend/app/services/snmp_proxy.py
Normal file
112
backend/app/services/snmp_proxy.py
Normal 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")
|
||||
Reference in New Issue
Block a user