a7a281cb11
First-party plugins (host_agent, http, snmp, traefik, unifi, docker) are now tracked under plugins/ and baked into the image, so they version atomically with core — ending the cross-repo import drift the roundtable->steward rename exposed. History for these files is preserved in the archived Roundtable-plugins repo. Plugin discovery becomes multi-root: PLUGIN_DIR (single) -> PLUGIN_DIRS (bundled first, then external) + PLUGIN_INSTALL_DIR. Bundled ships in the image; third-party plugins still mount at runtime into the external root (STEWARD_PLUGIN_DIR, default /data/plugins) and downloads/installs land there. Bundled shadows external on a name collision. - config.py: load_bootstrap returns plugin_dirs + plugin_install_dir - app.py: iterate PLUGIN_DIRS at the migration + load sites - migration_runner.py: discover_all_in() unions every plugin root - plugin_manager.py: resolve_plugin_path() (pure, first-root-wins); load / install / hot-reload span all roots; installs target the external root - settings/routes.py: _discover_plugins scans all roots, dedup bundled-first - Dockerfile: COPY plugins/ ; docker-compose: drop host bind, document external - tests/test_plugin_dirs.py: resolution, multi-root discovery, bootstrap split Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
171 lines
7.3 KiB
Python
171 lines
7.3 KiB
Python
# plugins/unifi/client.py
|
|
"""Async UniFi Network controller API client.
|
|
|
|
Supports UDM/UDM-Pro (firmware 2+) using the /proxy/network/ path prefix
|
|
and Bearer-token auth via the /api/auth/login endpoint.
|
|
|
|
On 401, re-authenticates automatically once before raising.
|
|
"""
|
|
from __future__ import annotations
|
|
import logging
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class UnifiClient:
|
|
def __init__(
|
|
self,
|
|
host: str,
|
|
username: str,
|
|
password: str,
|
|
site: str = "default",
|
|
verify_ssl: bool = False,
|
|
) -> None:
|
|
self._host = host.rstrip("/")
|
|
self._username = username
|
|
self._password = password
|
|
self._site = site
|
|
self._verify_ssl = verify_ssl
|
|
self._http: httpx.AsyncClient | None = None
|
|
self._csrf_token: str = ""
|
|
|
|
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
|
|
async def _ensure_http(self) -> None:
|
|
if self._http is None:
|
|
self._http = httpx.AsyncClient(
|
|
verify=self._verify_ssl,
|
|
timeout=15.0,
|
|
follow_redirects=True,
|
|
)
|
|
|
|
async def login(self) -> None:
|
|
await self._ensure_http()
|
|
resp = await self._http.post(
|
|
f"{self._host}/api/auth/login",
|
|
json={"username": self._username, "password": self._password, "rememberMe": False},
|
|
)
|
|
resp.raise_for_status()
|
|
self._csrf_token = resp.headers.get("X-CSRF-Token", "")
|
|
logger.debug("UniFi login OK (csrf=%s…)", self._csrf_token[:8] if self._csrf_token else "none")
|
|
|
|
async def close(self) -> None:
|
|
if self._http:
|
|
await self._http.aclose()
|
|
self._http = None
|
|
|
|
# ── API helpers ───────────────────────────────────────────────────────────
|
|
|
|
def _api_url(self, path: str) -> str:
|
|
return f"{self._host}/proxy/network/api/s/{self._site}{path}"
|
|
|
|
def _headers(self) -> dict:
|
|
return {"X-CSRF-Token": self._csrf_token} if self._csrf_token else {}
|
|
|
|
async def _get(self, path: str, params: dict | None = None) -> list | dict:
|
|
await self._ensure_http()
|
|
resp = await self._http.get(self._api_url(path), headers=self._headers(), params=params)
|
|
if resp.status_code == 401:
|
|
await self.login()
|
|
resp = await self._http.get(self._api_url(path), headers=self._headers(), params=params)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data.get("data", data)
|
|
|
|
# ── Core monitoring ───────────────────────────────────────────────────────
|
|
|
|
async def get_health(self) -> list[dict]:
|
|
"""Subsystem health (wan, wlan, lan, www)."""
|
|
result = await self._get("/stat/health")
|
|
return result if isinstance(result, list) else []
|
|
|
|
async def get_active_clients(self) -> list[dict]:
|
|
"""Currently connected clients."""
|
|
result = await self._get("/stat/sta")
|
|
return result if isinstance(result, list) else []
|
|
|
|
async def get_devices(self) -> list[dict]:
|
|
"""Managed network devices (APs, switches, gateways) with full detail."""
|
|
result = await self._get("/stat/device")
|
|
return result if isinstance(result, list) else []
|
|
|
|
# ── Events & alarms ───────────────────────────────────────────────────────
|
|
|
|
async def get_events(self, limit: int = 200) -> list[dict]:
|
|
"""Recent site events (client connects, IDS alerts, device state changes, etc.)."""
|
|
result = await self._get("/stat/event", params={"_limit": limit, "_sort": "-time"})
|
|
return result if isinstance(result, list) else []
|
|
|
|
async def get_alarms(self, archived: bool = False) -> list[dict]:
|
|
"""Active (or archived) alarms."""
|
|
result = await self._get("/stat/alarm")
|
|
if not isinstance(result, list):
|
|
return []
|
|
return [a for a in result if a.get("archived", False) == archived]
|
|
|
|
# ── Traffic & DPI ─────────────────────────────────────────────────────────
|
|
|
|
async def get_dpi_stats(self) -> list[dict]:
|
|
"""DPI per-client application/category traffic stats (cumulative counters)."""
|
|
result = await self._get("/stat/dpi")
|
|
return result if isinstance(result, list) else []
|
|
|
|
async def get_dpi_site_stats(self) -> list[dict]:
|
|
"""DPI site-level category totals (not per-client)."""
|
|
result = await self._get("/dpi")
|
|
return result if isinstance(result, list) else []
|
|
|
|
async def get_speedtest_status(self) -> dict | None:
|
|
"""Most recent WAN speed test result."""
|
|
result = await self._get("/stat/speedtest-status")
|
|
if isinstance(result, list) and result:
|
|
return result[0]
|
|
if isinstance(result, dict):
|
|
return result
|
|
return None
|
|
|
|
# ── Network inventory ─────────────────────────────────────────────────────
|
|
|
|
async def get_known_clients(self) -> list[dict]:
|
|
"""All historically seen clients (not just currently connected)."""
|
|
result = await self._get("/rest/user")
|
|
return result if isinstance(result, list) else []
|
|
|
|
async def get_networks(self) -> list[dict]:
|
|
"""Network/VLAN configurations."""
|
|
result = await self._get("/rest/networkconf")
|
|
return result if isinstance(result, list) else []
|
|
|
|
async def get_wlan_configs(self) -> list[dict]:
|
|
"""WLAN (SSID) configurations."""
|
|
result = await self._get("/rest/wlanconf")
|
|
return result if isinstance(result, list) else []
|
|
|
|
async def get_port_forwards(self) -> list[dict]:
|
|
"""Port forwarding rules."""
|
|
result = await self._get("/rest/portforwarding")
|
|
return result if isinstance(result, list) else []
|
|
|
|
async def get_firewall_rules(self) -> list[dict]:
|
|
"""Firewall rules."""
|
|
result = await self._get("/rest/firewallrule")
|
|
return result if isinstance(result, list) else []
|
|
|
|
async def get_firewall_groups(self) -> list[dict]:
|
|
"""Firewall groups (IP sets / port sets referenced by rules)."""
|
|
result = await self._get("/rest/firewallgroup")
|
|
return result if isinstance(result, list) else []
|
|
|
|
# ── System ────────────────────────────────────────────────────────────────
|
|
|
|
async def get_sysinfo(self) -> dict | None:
|
|
"""Controller system information (version, uptime, hostname)."""
|
|
result = await self._get("/stat/sysinfo")
|
|
if isinstance(result, list) and result:
|
|
return result[0]
|
|
if isinstance(result, dict):
|
|
return result
|
|
return None
|