Add ruff config to exclude alembic E402, SQLAlchemy F821, and pre-existing E501 line-length issues. Auto-fix 69 unused imports and 2 f-strings without placeholders. Manually fix 8 unused variables. Apply ruff format to 127 files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
297 lines
10 KiB
Python
297 lines
10 KiB
Python
"""
|
|
Client device discovery API endpoint.
|
|
|
|
Fetches ARP, DHCP lease, and wireless registration data from a RouterOS device
|
|
via the NATS command proxy, merges by MAC address, and returns a unified client list.
|
|
|
|
All routes are tenant-scoped under:
|
|
/api/tenants/{tenant_id}/devices/{device_id}/clients
|
|
|
|
RLS is enforced via get_db() (app_user engine with tenant context).
|
|
RBAC: viewer and above (read-only operation).
|
|
"""
|
|
|
|
import asyncio
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
import structlog
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.middleware.rbac import require_min_role
|
|
from app.middleware.tenant_context import CurrentUser, get_current_user
|
|
from app.models.device import Device
|
|
from app.services import routeros_proxy
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
router = APIRouter(tags=["clients"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _check_tenant_access(
|
|
current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession
|
|
) -> None:
|
|
"""Verify the current user is allowed to access the given tenant."""
|
|
if current_user.is_super_admin:
|
|
from app.database import set_tenant_context
|
|
|
|
await set_tenant_context(db, str(tenant_id))
|
|
return
|
|
if current_user.tenant_id != tenant_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied: you do not belong to this tenant.",
|
|
)
|
|
|
|
|
|
async def _check_device_online(db: AsyncSession, device_id: uuid.UUID) -> Device:
|
|
"""Verify the device exists and is online. Returns the Device object."""
|
|
result = await db.execute(
|
|
select(Device).where(Device.id == device_id) # type: ignore[arg-type]
|
|
)
|
|
device = result.scalar_one_or_none()
|
|
if device is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Device {device_id} not found",
|
|
)
|
|
if device.status != "online":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Device is offline -- client discovery requires a live connection.",
|
|
)
|
|
return device
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MAC-address merge logic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _normalize_mac(mac: str) -> str:
|
|
"""Normalize a MAC address to uppercase colon-separated format."""
|
|
return mac.strip().upper().replace("-", ":")
|
|
|
|
|
|
def _merge_client_data(
|
|
arp_data: list[dict[str, Any]],
|
|
dhcp_data: list[dict[str, Any]],
|
|
wireless_data: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""Merge ARP, DHCP lease, and wireless registration data by MAC address.
|
|
|
|
ARP entries are the base. DHCP enriches with hostname. Wireless enriches
|
|
with signal/tx/rx/uptime and marks the client as wireless.
|
|
"""
|
|
# Index DHCP leases by MAC
|
|
dhcp_by_mac: dict[str, dict[str, Any]] = {}
|
|
for lease in dhcp_data:
|
|
mac_raw = lease.get("mac-address") or lease.get("active-mac-address", "")
|
|
if mac_raw:
|
|
dhcp_by_mac[_normalize_mac(mac_raw)] = lease
|
|
|
|
# Index wireless registrations by MAC
|
|
wireless_by_mac: dict[str, dict[str, Any]] = {}
|
|
for reg in wireless_data:
|
|
mac_raw = reg.get("mac-address", "")
|
|
if mac_raw:
|
|
wireless_by_mac[_normalize_mac(mac_raw)] = reg
|
|
|
|
# Track which MACs we've already processed (from ARP)
|
|
seen_macs: set[str] = set()
|
|
clients: list[dict[str, Any]] = []
|
|
|
|
# Start with ARP entries as base
|
|
for entry in arp_data:
|
|
mac_raw = entry.get("mac-address", "")
|
|
if not mac_raw:
|
|
continue
|
|
mac = _normalize_mac(mac_raw)
|
|
if mac in seen_macs:
|
|
continue
|
|
seen_macs.add(mac)
|
|
|
|
# Determine status: ARP complete flag or dynamic flag
|
|
is_complete = entry.get("complete", "true").lower() == "true"
|
|
arp_status = "reachable" if is_complete else "stale"
|
|
|
|
client: dict[str, Any] = {
|
|
"mac": mac,
|
|
"ip": entry.get("address", ""),
|
|
"interface": entry.get("interface", ""),
|
|
"hostname": None,
|
|
"status": arp_status,
|
|
"signal_strength": None,
|
|
"tx_rate": None,
|
|
"rx_rate": None,
|
|
"uptime": None,
|
|
"is_wireless": False,
|
|
}
|
|
|
|
# Enrich with DHCP data
|
|
dhcp = dhcp_by_mac.get(mac)
|
|
if dhcp:
|
|
client["hostname"] = dhcp.get("host-name") or None
|
|
dhcp_status = dhcp.get("status", "")
|
|
if dhcp_status:
|
|
client["dhcp_status"] = dhcp_status
|
|
|
|
# Enrich with wireless data
|
|
wireless = wireless_by_mac.get(mac)
|
|
if wireless:
|
|
client["is_wireless"] = True
|
|
client["signal_strength"] = wireless.get("signal-strength") or None
|
|
client["tx_rate"] = wireless.get("tx-rate") or None
|
|
client["rx_rate"] = wireless.get("rx-rate") or None
|
|
client["uptime"] = wireless.get("uptime") or None
|
|
|
|
clients.append(client)
|
|
|
|
# Also include DHCP-only entries (no ARP match -- e.g. expired leases)
|
|
for mac, lease in dhcp_by_mac.items():
|
|
if mac in seen_macs:
|
|
continue
|
|
seen_macs.add(mac)
|
|
|
|
client = {
|
|
"mac": mac,
|
|
"ip": lease.get("active-address") or lease.get("address", ""),
|
|
"interface": lease.get("active-server") or "",
|
|
"hostname": lease.get("host-name") or None,
|
|
"status": "stale", # No ARP entry = not actively reachable
|
|
"signal_strength": None,
|
|
"tx_rate": None,
|
|
"rx_rate": None,
|
|
"uptime": None,
|
|
"is_wireless": mac in wireless_by_mac,
|
|
}
|
|
|
|
wireless = wireless_by_mac.get(mac)
|
|
if wireless:
|
|
client["signal_strength"] = wireless.get("signal-strength") or None
|
|
client["tx_rate"] = wireless.get("tx-rate") or None
|
|
client["rx_rate"] = wireless.get("rx-rate") or None
|
|
client["uptime"] = wireless.get("uptime") or None
|
|
|
|
clients.append(client)
|
|
|
|
return clients
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/tenants/{tenant_id}/devices/{device_id}/clients",
|
|
summary="List connected client devices (ARP + DHCP + wireless)",
|
|
)
|
|
async def list_clients(
|
|
tenant_id: uuid.UUID,
|
|
device_id: uuid.UUID,
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
_role: CurrentUser = Depends(require_min_role("viewer")),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict[str, Any]:
|
|
"""Discover all client devices connected to a MikroTik device.
|
|
|
|
Fetches ARP table, DHCP server leases, and wireless registration table
|
|
in parallel, then merges by MAC address into a unified client list.
|
|
|
|
Wireless fetch failure is non-fatal (device may not have wireless interfaces).
|
|
DHCP fetch failure is non-fatal (device may not run a DHCP server).
|
|
ARP fetch failure is fatal (core data source).
|
|
"""
|
|
await _check_tenant_access(current_user, tenant_id, db)
|
|
await _check_device_online(db, device_id)
|
|
|
|
device_id_str = str(device_id)
|
|
|
|
# Fetch all three sources in parallel
|
|
arp_result, dhcp_result, wireless_result = await asyncio.gather(
|
|
routeros_proxy.execute_command(device_id_str, "/ip/arp/print"),
|
|
routeros_proxy.execute_command(device_id_str, "/ip/dhcp-server/lease/print"),
|
|
routeros_proxy.execute_command(
|
|
device_id_str, "/interface/wireless/registration-table/print"
|
|
),
|
|
return_exceptions=True,
|
|
)
|
|
|
|
# ARP is required -- if it failed, return 502
|
|
if isinstance(arp_result, Exception):
|
|
logger.error("ARP fetch exception", device_id=device_id_str, error=str(arp_result))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"Failed to fetch ARP table: {arp_result}",
|
|
)
|
|
if not arp_result.get("success"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=arp_result.get("error", "Failed to fetch ARP table"),
|
|
)
|
|
|
|
arp_data: list[dict[str, Any]] = arp_result.get("data", [])
|
|
|
|
# DHCP is optional -- log warning and continue with empty data
|
|
dhcp_data: list[dict[str, Any]] = []
|
|
if isinstance(dhcp_result, Exception):
|
|
logger.warning(
|
|
"DHCP fetch exception (continuing without DHCP data)",
|
|
device_id=device_id_str,
|
|
error=str(dhcp_result),
|
|
)
|
|
elif not dhcp_result.get("success"):
|
|
logger.warning(
|
|
"DHCP fetch failed (continuing without DHCP data)",
|
|
device_id=device_id_str,
|
|
error=dhcp_result.get("error"),
|
|
)
|
|
else:
|
|
dhcp_data = dhcp_result.get("data", [])
|
|
|
|
# Wireless is optional -- many devices have no wireless interfaces
|
|
wireless_data: list[dict[str, Any]] = []
|
|
if isinstance(wireless_result, Exception):
|
|
logger.warning(
|
|
"Wireless fetch exception (device may not have wireless interfaces)",
|
|
device_id=device_id_str,
|
|
error=str(wireless_result),
|
|
)
|
|
elif not wireless_result.get("success"):
|
|
logger.warning(
|
|
"Wireless fetch failed (device may not have wireless interfaces)",
|
|
device_id=device_id_str,
|
|
error=wireless_result.get("error"),
|
|
)
|
|
else:
|
|
wireless_data = wireless_result.get("data", [])
|
|
|
|
# Merge by MAC address
|
|
clients = _merge_client_data(arp_data, dhcp_data, wireless_data)
|
|
|
|
logger.info(
|
|
"client_discovery_complete",
|
|
device_id=device_id_str,
|
|
tenant_id=str(tenant_id),
|
|
arp_count=len(arp_data),
|
|
dhcp_count=len(dhcp_data),
|
|
wireless_count=len(wireless_data),
|
|
merged_count=len(clients),
|
|
)
|
|
|
|
return {
|
|
"clients": clients,
|
|
"device_id": device_id_str,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
}
|