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) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
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):
|
class DeviceCreate(BaseModel):
|
||||||
"""Schema for creating a new device."""
|
"""Schema for creating a new device (RouterOS or SNMP)."""
|
||||||
|
|
||||||
hostname: str
|
hostname: str
|
||||||
ip_address: str
|
ip_address: str
|
||||||
api_port: int = 8728
|
api_port: int = 8728
|
||||||
api_ssl_port: int = 8729
|
api_ssl_port: int = 8729
|
||||||
username: str
|
# RouterOS credentials — optional for SNMP devices
|
||||||
password: str
|
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):
|
class DeviceUpdate(BaseModel):
|
||||||
@@ -36,6 +71,10 @@ class DeviceUpdate(BaseModel):
|
|||||||
longitude: Optional[float] = None
|
longitude: Optional[float] = None
|
||||||
tls_mode: Optional[str] = None
|
tls_mode: Optional[str] = None
|
||||||
credential_profile_id: Optional[uuid.UUID] = 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")
|
@field_validator("tls_mode")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -48,6 +87,13 @@ class DeviceUpdate(BaseModel):
|
|||||||
raise ValueError(f"tls_mode must be one of: {', '.join(sorted(allowed))}")
|
raise ValueError(f"tls_mode must be one of: {', '.join(sorted(allowed))}")
|
||||||
return v
|
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):
|
class DeviceTagRef(BaseModel):
|
||||||
"""Minimal tag info embedded in device responses."""
|
"""Minimal tag info embedded in device responses."""
|
||||||
@@ -87,6 +133,12 @@ class DeviceResponse(BaseModel):
|
|||||||
longitude: Optional[float] = None
|
longitude: Optional[float] = None
|
||||||
status: str
|
status: str
|
||||||
tls_mode: str = "auto"
|
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] = []
|
tags: list[DeviceTagRef] = []
|
||||||
groups: list[DeviceGroupRef] = []
|
groups: list[DeviceGroupRef] = []
|
||||||
site_id: Optional[uuid.UUID] = None
|
site_id: Optional[uuid.UUID] = None
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ def _build_device_response(device: Device) -> DeviceResponse:
|
|||||||
longitude=device.longitude,
|
longitude=device.longitude,
|
||||||
status=device.status,
|
status=device.status,
|
||||||
tls_mode=device.tls_mode,
|
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,
|
tags=tags,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
site_id=device.site_id,
|
site_id=device.site_id,
|
||||||
@@ -144,39 +149,61 @@ async def create_device(
|
|||||||
encryption_key: bytes,
|
encryption_key: bytes,
|
||||||
) -> DeviceResponse:
|
) -> DeviceResponse:
|
||||||
"""
|
"""
|
||||||
Create a new device.
|
Create a new device (RouterOS or SNMP).
|
||||||
|
|
||||||
- Validates TCP connectivity (api_port or api_ssl_port must be reachable).
|
- RouterOS: validates TCP connectivity, encrypts username/password.
|
||||||
- Encrypts credentials before storage.
|
- SNMP: skips TCP probe (UDP), encrypts community string if provided inline.
|
||||||
- Status set to "unknown" until the Go poller runs a full auth check (Phase 2).
|
- Status set to "unknown" until the poller runs a full check.
|
||||||
"""
|
"""
|
||||||
# Test connectivity before accepting the device
|
is_snmp = data.device_type == "snmp"
|
||||||
api_reachable = await _tcp_reachable(data.ip_address, data.api_port)
|
|
||||||
ssl_reachable = await _tcp_reachable(data.ip_address, data.api_ssl_port)
|
|
||||||
|
|
||||||
if not api_reachable and not ssl_reachable:
|
# TCP reachability check — only for RouterOS devices (SNMP uses UDP)
|
||||||
from fastapi import HTTPException, status
|
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(
|
if not api_reachable and not ssl_reachable:
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
from fastapi import HTTPException, status
|
||||||
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 (new writes go through Transit)
|
raise HTTPException(
|
||||||
credentials_json = json.dumps({"username": data.username, "password": data.password})
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
transit_ciphertext = await encrypt_credentials_transit(credentials_json, str(tenant_id))
|
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(
|
device = Device(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
hostname=data.hostname,
|
hostname=data.hostname,
|
||||||
ip_address=data.ip_address,
|
ip_address=data.ip_address,
|
||||||
|
device_type=data.device_type,
|
||||||
api_port=data.api_port,
|
api_port=data.api_port,
|
||||||
api_ssl_port=data.api_ssl_port,
|
api_ssl_port=data.api_ssl_port,
|
||||||
encrypted_credentials_transit=transit_ciphertext,
|
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",
|
status="unknown",
|
||||||
)
|
)
|
||||||
db.add(device)
|
db.add(device)
|
||||||
@@ -321,6 +348,13 @@ async def update_device(
|
|||||||
device.longitude = data.longitude
|
device.longitude = data.longitude
|
||||||
if data.tls_mode is not None:
|
if data.tls_mode is not None:
|
||||||
device.tls_mode = data.tls_mode
|
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
|
# Assign credential profile if provided
|
||||||
if data.credential_profile_id is not None:
|
if data.credential_profile_id is not None:
|
||||||
|
|||||||
Reference in New Issue
Block a user