From bdf5b5471387c90322c3290fb7a92e324d408cee Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sun, 22 Mar 2026 01:11:10 -0500 Subject: [PATCH] fix(api): add SNMP fields to device create/read/update schemas and service DeviceCreate now accepts device_type, snmp_port, snmp_version, snmp_profile_id, credential_profile_id, and community string. Username/password are optional (not needed for SNMP devices). A model validator ensures at least one credential method is provided. DeviceResponse and DeviceUpdate include the same SNMP fields so list/detail endpoints return them and users can modify them. The create_device service skips TCP probe for SNMP devices (UDP), encrypts inline community strings via Transit, and sets all SNMP columns on the Device ORM object. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/schemas/device.py | 60 +++++++++++++++++++++++++-- backend/app/services/device.py | 74 +++++++++++++++++++++++++--------- 2 files changed, 110 insertions(+), 24 deletions(-) diff --git a/backend/app/schemas/device.py b/backend/app/schemas/device.py index b9d236e..c0b6bf8 100644 --- a/backend/app/schemas/device.py +++ b/backend/app/schemas/device.py @@ -4,7 +4,7 @@ import uuid from datetime import datetime from typing import Optional -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, field_validator, model_validator # --------------------------------------------------------------------------- @@ -13,14 +13,49 @@ from pydantic import BaseModel, field_validator class DeviceCreate(BaseModel): - """Schema for creating a new device.""" + """Schema for creating a new device (RouterOS or SNMP).""" hostname: str ip_address: str api_port: int = 8728 api_ssl_port: int = 8729 - username: str - password: str + # RouterOS credentials — optional for SNMP devices + username: Optional[str] = None + password: Optional[str] = None + # SNMP / device-type fields + device_type: str = "routeros" + snmp_port: int = 161 + snmp_version: Optional[str] = None # "v1", "v2c", "v3" + snmp_profile_id: Optional[str] = None + credential_profile_id: Optional[str] = None + community: Optional[str] = None # inline v2c community string + + @field_validator("device_type") + @classmethod + def validate_device_type(cls, v: str) -> str: + if v not in ("routeros", "snmp"): + raise ValueError("device_type must be 'routeros' or 'snmp'") + return v + + @field_validator("snmp_version") + @classmethod + def validate_snmp_version(cls, v: Optional[str]) -> Optional[str]: + if v is not None and v not in ("v1", "v2c", "v3"): + raise ValueError("snmp_version must be 'v1', 'v2c', or 'v3'") + return v + + @model_validator(mode="after") + def validate_credentials(self) -> "DeviceCreate": + """Ensure some form of authentication is provided.""" + has_user_pass = self.username is not None and self.password is not None + has_profile = self.credential_profile_id is not None + has_community = self.community is not None + if not has_user_pass and not has_profile and not has_community: + raise ValueError( + "Credentials required: provide (username + password), " + "credential_profile_id, or community string" + ) + return self class DeviceUpdate(BaseModel): @@ -36,6 +71,10 @@ class DeviceUpdate(BaseModel): longitude: Optional[float] = None tls_mode: Optional[str] = None credential_profile_id: Optional[uuid.UUID] = None + # SNMP fields + snmp_port: Optional[int] = None + snmp_version: Optional[str] = None + snmp_profile_id: Optional[uuid.UUID] = None @field_validator("tls_mode") @classmethod @@ -48,6 +87,13 @@ class DeviceUpdate(BaseModel): raise ValueError(f"tls_mode must be one of: {', '.join(sorted(allowed))}") return v + @field_validator("snmp_version") + @classmethod + def validate_snmp_version(cls, v: Optional[str]) -> Optional[str]: + if v is not None and v not in ("v1", "v2c", "v3"): + raise ValueError("snmp_version must be 'v1', 'v2c', or 'v3'") + return v + class DeviceTagRef(BaseModel): """Minimal tag info embedded in device responses.""" @@ -87,6 +133,12 @@ class DeviceResponse(BaseModel): longitude: Optional[float] = None status: str tls_mode: str = "auto" + # SNMP / device-type fields + device_type: str = "routeros" + snmp_port: Optional[int] = None + snmp_version: Optional[str] = None + snmp_profile_id: Optional[uuid.UUID] = None + credential_profile_id: Optional[uuid.UUID] = None tags: list[DeviceTagRef] = [] groups: list[DeviceGroupRef] = [] site_id: Optional[uuid.UUID] = None diff --git a/backend/app/services/device.py b/backend/app/services/device.py index 1a67f02..067185a 100644 --- a/backend/app/services/device.py +++ b/backend/app/services/device.py @@ -111,6 +111,11 @@ def _build_device_response(device: Device) -> DeviceResponse: longitude=device.longitude, status=device.status, tls_mode=device.tls_mode, + device_type=device.device_type, + snmp_port=device.snmp_port, + snmp_version=device.snmp_version, + snmp_profile_id=device.snmp_profile_id, + credential_profile_id=device.credential_profile_id, tags=tags, groups=groups, site_id=device.site_id, @@ -144,39 +149,61 @@ async def create_device( encryption_key: bytes, ) -> DeviceResponse: """ - Create a new device. + Create a new device (RouterOS or SNMP). - - Validates TCP connectivity (api_port or api_ssl_port must be reachable). - - Encrypts credentials before storage. - - Status set to "unknown" until the Go poller runs a full auth check (Phase 2). + - RouterOS: validates TCP connectivity, encrypts username/password. + - SNMP: skips TCP probe (UDP), encrypts community string if provided inline. + - Status set to "unknown" until the poller runs a full check. """ - # Test connectivity before accepting the device - api_reachable = await _tcp_reachable(data.ip_address, data.api_port) - ssl_reachable = await _tcp_reachable(data.ip_address, data.api_ssl_port) + is_snmp = data.device_type == "snmp" - if not api_reachable and not ssl_reachable: - from fastapi import HTTPException, status + # TCP reachability check — only for RouterOS devices (SNMP uses UDP) + if not is_snmp: + api_reachable = await _tcp_reachable(data.ip_address, data.api_port) + ssl_reachable = await _tcp_reachable(data.ip_address, data.api_ssl_port) - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=( - f"Cannot reach {data.ip_address} on port {data.api_port} " - f"(RouterOS API) or {data.api_ssl_port} (RouterOS SSL API). " - "Verify the IP address and that the RouterOS API is enabled." - ), - ) + if not api_reachable and not ssl_reachable: + from fastapi import HTTPException, status - # Encrypt credentials via OpenBao Transit (new writes go through Transit) - credentials_json = json.dumps({"username": data.username, "password": data.password}) - transit_ciphertext = await encrypt_credentials_transit(credentials_json, str(tenant_id)) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + f"Cannot reach {data.ip_address} on port {data.api_port} " + f"(RouterOS API) or {data.api_ssl_port} (RouterOS SSL API). " + "Verify the IP address and that the RouterOS API is enabled." + ), + ) + + # Encrypt credentials via OpenBao Transit + transit_ciphertext = None + if data.username is not None and data.password is not None: + # RouterOS username/password or SNMP v3 with user/pass + credentials_json = json.dumps({"username": data.username, "password": data.password}) + transit_ciphertext = await encrypt_credentials_transit(credentials_json, str(tenant_id)) + elif data.community is not None: + # Inline SNMP v2c community string — store as encrypted credential + credentials_json = json.dumps({"community": data.community, "type": "snmp_v2c"}) + transit_ciphertext = await encrypt_credentials_transit(credentials_json, str(tenant_id)) + + # Resolve credential_profile_id and snmp_profile_id (string -> UUID) + credential_profile_uuid = ( + uuid.UUID(data.credential_profile_id) if data.credential_profile_id else None + ) + snmp_profile_uuid = uuid.UUID(data.snmp_profile_id) if data.snmp_profile_id else None device = Device( tenant_id=tenant_id, hostname=data.hostname, ip_address=data.ip_address, + device_type=data.device_type, api_port=data.api_port, api_ssl_port=data.api_ssl_port, encrypted_credentials_transit=transit_ciphertext, + # SNMP fields + snmp_port=data.snmp_port if is_snmp else 161, + snmp_version=data.snmp_version if is_snmp else None, + snmp_profile_id=snmp_profile_uuid, + credential_profile_id=credential_profile_uuid, status="unknown", ) db.add(device) @@ -321,6 +348,13 @@ async def update_device( device.longitude = data.longitude if data.tls_mode is not None: device.tls_mode = data.tls_mode + # SNMP fields + if data.snmp_port is not None: + device.snmp_port = data.snmp_port + if data.snmp_version is not None: + device.snmp_version = data.snmp_version + if data.snmp_profile_id is not None: + device.snmp_profile_id = data.snmp_profile_id # Assign credential profile if provided if data.credential_profile_id is not None: