feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
297
backend/app/routers/clients.py
Normal file
297
backend/app/routers/clients.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
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(),
|
||||
}
|
||||
Reference in New Issue
Block a user