|
|
|
@@ -0,0 +1,268 @@
|
|
|
|
|
"""The unifi plugin's MCP tools and its get_overview findings (#5839).
|
|
|
|
|
|
|
|
|
|
Registered through register_mcp_tools() in __init__.py, so they exist only
|
|
|
|
|
while the plugin is enabled. Everything here is read from what the poller
|
|
|
|
|
stored; nothing reaches the UniFi controller.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import or_, select
|
|
|
|
|
|
|
|
|
|
from steward.mcp._context import mcp_app
|
|
|
|
|
from steward.mcp.tools._shape import (
|
|
|
|
|
age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, rnd, summarize,
|
|
|
|
|
)
|
|
|
|
|
from steward.mcp.tools.overview import finding
|
|
|
|
|
from steward.models.users import UserRole
|
|
|
|
|
|
|
|
|
|
from .models import (
|
|
|
|
|
UnifiAlarm, UnifiClientSnapshot, UnifiDevice, UnifiEvent, UnifiFwRule, UnifiKnownClient,
|
|
|
|
|
UnifiNetwork, UnifiPortForward, UnifiSpeedtestResult, UnifiWanStat, UnifiWlanStat,
|
|
|
|
|
)
|
|
|
|
|
from .routes import device_state, devices_in_order, history
|
|
|
|
|
|
|
|
|
|
# The poller's readings are trusted while newer than this many poll intervals:
|
|
|
|
|
# an older "WAN down" is a poller that stopped, not a WAN that went down.
|
|
|
|
|
FRESH_POLLS = 5
|
|
|
|
|
DEVICE_STATES = ("down", "degraded", "pending", "up")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _poll_seconds() -> int:
|
|
|
|
|
cfg = mcp_app().config.get("PLUGINS", {}).get("unifi", {})
|
|
|
|
|
return int(cfg.get("poll_interval_seconds", 60) or 60)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _device_row(d, now) -> dict:
|
|
|
|
|
status, label = device_state(d.state)
|
|
|
|
|
return {"name": d.name or d.mac, "mac": d.mac, "model": d.model, "type": d.device_type,
|
|
|
|
|
"ip": d.ip, "status": status, "state": label, "version": d.version,
|
|
|
|
|
"uptime_s": d.uptime_seconds, "last_seen_at": iso(d.last_seen),
|
|
|
|
|
"last_seen_age_s": age_s(d.last_seen, now)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_network(range: str = "24h") -> dict:
|
|
|
|
|
"""The UniFi network at a glance: the WAN, clients, Wi-Fi networks, the
|
|
|
|
|
last speed test, and devices by status.
|
|
|
|
|
|
|
|
|
|
range: for the WAN latency and client-count series: "1h", "6h", "24h"
|
|
|
|
|
(default), "7d" or "30d".
|
|
|
|
|
|
|
|
|
|
`wan`: up or down, its IP, latency, rx/tx bytes per second and uptime,
|
|
|
|
|
from the newest poll (`polled_at`); `stale: true` when that poll is old,
|
|
|
|
|
so the figures are not current. `clients`: connected now (wireless,
|
|
|
|
|
wired, guest) and the series. `wlans`: per SSID and radio (ng 2.4 GHz,
|
|
|
|
|
na 5 GHz, 6e 6 GHz) the clients, channel and UniFi's satisfaction score
|
|
|
|
|
(0-100). `speedtests`: the newest few. `devices`: counts by status
|
|
|
|
|
(list_devices names them).
|
|
|
|
|
"""
|
|
|
|
|
since, key = lookback(range)
|
|
|
|
|
now = now_utc()
|
|
|
|
|
async with db_session() as db:
|
|
|
|
|
wan_history, client_history = await history(db, since, max(60, int((now - since).total_seconds() // 60)))
|
|
|
|
|
wan = (await db.execute(select(UnifiWanStat).order_by(UnifiWanStat.scraped_at.desc()).limit(1))).scalar_one_or_none()
|
|
|
|
|
clients = (await db.execute(select(UnifiClientSnapshot)
|
|
|
|
|
.order_by(UnifiClientSnapshot.scraped_at.desc()).limit(1))).scalar_one_or_none()
|
|
|
|
|
wlans = (await db.execute(select(UnifiWlanStat).distinct(UnifiWlanStat.ssid, UnifiWlanStat.radio)
|
|
|
|
|
.order_by(UnifiWlanStat.ssid, UnifiWlanStat.radio,
|
|
|
|
|
UnifiWlanStat.scraped_at.desc()))).scalars().all()
|
|
|
|
|
tests = (await db.execute(select(UnifiSpeedtestResult)
|
|
|
|
|
.order_by(UnifiSpeedtestResult.run_at.desc()).limit(5))).scalars().all()
|
|
|
|
|
devices = (await db.execute(select(UnifiDevice))).scalars().all()
|
|
|
|
|
fresh = timedelta(seconds=FRESH_POLLS * _poll_seconds())
|
|
|
|
|
counts: dict[str, int] = {}
|
|
|
|
|
for d in devices:
|
|
|
|
|
counts[device_state(d.state)[0]] = counts.get(device_state(d.state)[0], 0) + 1
|
|
|
|
|
return {
|
|
|
|
|
"range": key,
|
|
|
|
|
"wan": ({"up": wan.is_up, "ip": wan.wan_ip, "latency_ms": rnd(wan.latency_ms, 1),
|
|
|
|
|
"rx_bytes_per_s": rnd(wan.rx_bytes_rate, 0), "tx_bytes_per_s": rnd(wan.tx_bytes_rate, 0),
|
|
|
|
|
"uptime_s": wan.uptime_seconds, "polled_at": iso(wan.scraped_at),
|
|
|
|
|
"stale": now - wan.scraped_at > fresh,
|
|
|
|
|
"latency_ms_series": summarize([(r.bucket, float(r.latency_ms)) for r in wan_history
|
|
|
|
|
if r.latency_ms is not None])} if wan else None),
|
|
|
|
|
"clients": ({"total": clients.total_clients, "wireless": clients.wireless_clients,
|
|
|
|
|
"wired": clients.wired_clients, "guest": clients.guest_clients,
|
|
|
|
|
"polled_at": iso(clients.scraped_at),
|
|
|
|
|
"series": summarize([(r.bucket, float(r.total_clients)) for r in client_history])}
|
|
|
|
|
if clients else None),
|
|
|
|
|
"wlans": [{"ssid": w.ssid, "radio": w.radio, "clients": w.num_sta, "channel": w.channel,
|
|
|
|
|
"satisfaction": w.satisfaction, "polled_at": iso(w.scraped_at)} for w in wlans],
|
|
|
|
|
"speedtests": [{"run_at": iso(t.run_at), "download_mbps": rnd(t.download_mbps, 1),
|
|
|
|
|
"upload_mbps": rnd(t.upload_mbps, 1), "latency_ms": rnd(t.latency_ms, 1),
|
|
|
|
|
"server": t.server_name} for t in tests],
|
|
|
|
|
"devices": counts,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def list_devices(status: str = "") -> dict:
|
|
|
|
|
"""UniFi's network devices (gateways, switches, access points): offline
|
|
|
|
|
and failing first, then those in a transition, then working ones.
|
|
|
|
|
|
|
|
|
|
status: "down" (offline, inform error, adoption failed), "degraded"
|
|
|
|
|
(firmware mismatch, heartbeat missed, isolated), "pending" (adopting,
|
|
|
|
|
upgrading, provisioning), "up"; empty for all.
|
|
|
|
|
"""
|
|
|
|
|
if status and status not in DEVICE_STATES:
|
|
|
|
|
raise ValueError(f"status must be one of {', '.join(DEVICE_STATES)}, or empty for all.")
|
|
|
|
|
now = now_utc()
|
|
|
|
|
async with db_session() as db:
|
|
|
|
|
devices = devices_in_order((await db.execute(select(UnifiDevice))).scalars().all())
|
|
|
|
|
rows = [_device_row(d, now) for d in devices]
|
|
|
|
|
return {"devices": [r for r in rows if not status or r["status"] == status], "total": len(rows)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def list_clients(search: str = "", active: bool = False, limit: int = 50) -> dict:
|
|
|
|
|
"""Network clients UniFi has seen, most recently seen first.
|
|
|
|
|
|
|
|
|
|
search: part of a hostname, alias, IP, MAC or vendor; empty for all.
|
|
|
|
|
active: true for the top talkers of the newest poll instead (with their
|
|
|
|
|
current tx/rx rates, signal and SSID).
|
|
|
|
|
limit: default 50, max 500.
|
|
|
|
|
|
|
|
|
|
A known client carries `first_seen_at` / `last_seen_at`, its vendor (from
|
|
|
|
|
the MAC), `blocked`, and the alias and note set in UniFi.
|
|
|
|
|
"""
|
|
|
|
|
limit = clamp_limit(limit, 50, 500)
|
|
|
|
|
now = now_utc()
|
|
|
|
|
async with db_session() as db:
|
|
|
|
|
if active:
|
|
|
|
|
snap = (await db.execute(select(UnifiClientSnapshot)
|
|
|
|
|
.order_by(UnifiClientSnapshot.scraped_at.desc()).limit(1))).scalar_one_or_none()
|
|
|
|
|
top = json.loads(snap.top_clients_json) if snap else []
|
|
|
|
|
if search:
|
|
|
|
|
needle = search.lower()
|
|
|
|
|
top = [c for c in top if any(needle in str(c.get(k) or "").lower()
|
|
|
|
|
for k in ("hostname", "ip", "mac", "essid"))]
|
|
|
|
|
out = bounded(top, limit, key="clients")
|
|
|
|
|
out["polled_at"] = iso(snap.scraped_at) if snap else None
|
|
|
|
|
return out
|
|
|
|
|
q = select(UnifiKnownClient)
|
|
|
|
|
if search:
|
|
|
|
|
like = f"%{search.strip()}%"
|
|
|
|
|
q = q.where(or_(*(col.ilike(like) for col in (
|
|
|
|
|
UnifiKnownClient.hostname, UnifiKnownClient.name, UnifiKnownClient.ip,
|
|
|
|
|
UnifiKnownClient.mac, UnifiKnownClient.mac_vendor))))
|
|
|
|
|
known = (await db.execute(q.order_by(UnifiKnownClient.last_seen.desc().nullslast())
|
|
|
|
|
.limit(limit + 1))).scalars().all()
|
|
|
|
|
rows = [{"mac": c.mac, "hostname": c.hostname, "alias": c.name, "ip": c.ip, "vendor": c.mac_vendor,
|
|
|
|
|
"blocked": c.is_blocked, "note": c.note, "first_seen_at": iso(c.first_seen),
|
|
|
|
|
"last_seen_at": iso(c.last_seen), "last_seen_age_s": age_s(c.last_seen, now)} for c in known]
|
|
|
|
|
out = {"clients": rows[:limit], "truncated": len(rows) > limit}
|
|
|
|
|
if out["truncated"]:
|
|
|
|
|
out["more"] = f"More than {limit}. Pass a larger `limit`, or a `search`."
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def list_network_events(range: str = "24h", category: str = "", search: str = "",
|
|
|
|
|
limit: int = 100) -> dict:
|
|
|
|
|
"""UniFi's site events, newest first: clients joining and leaving, devices
|
|
|
|
|
restarting or losing contact, WAN changes, IDS hits.
|
|
|
|
|
|
|
|
|
|
range: "1h", "6h", "24h" (default), "7d" or "30d".
|
|
|
|
|
category: UniFi's own, e.g. "wlan", "wired", "ap", "wan", "ids",
|
|
|
|
|
"system"; empty for all.
|
|
|
|
|
search: part of the message or the event key (e.g. "EVT_AP_Lost_Contact").
|
|
|
|
|
limit: default 100, max 500.
|
|
|
|
|
"""
|
|
|
|
|
since, key = lookback(range)
|
|
|
|
|
limit = clamp_limit(limit, 100, 500)
|
|
|
|
|
q = select(UnifiEvent).where(UnifiEvent.event_time >= since)
|
|
|
|
|
if category:
|
|
|
|
|
q = q.where(UnifiEvent.category == category)
|
|
|
|
|
if search:
|
|
|
|
|
like = f"%{search.strip()}%"
|
|
|
|
|
q = q.where(or_(UnifiEvent.message.ilike(like), UnifiEvent.event_key.ilike(like)))
|
|
|
|
|
async with db_session() as db:
|
|
|
|
|
rows = (await db.execute(q.order_by(UnifiEvent.event_time.desc()).limit(limit + 1))).scalars().all()
|
|
|
|
|
events = [{"at": iso(e.event_time), "key": e.event_key, "category": e.category, "message": e.message,
|
|
|
|
|
"source_mac": e.source_mac, "source_ip": e.source_ip} for e in rows]
|
|
|
|
|
out = {"range": key, "events": events[:limit], "truncated": len(events) > limit}
|
|
|
|
|
if out["truncated"]:
|
|
|
|
|
out["more"] = f"More than {limit}. Pass a `category`, a `search`, a shorter `range` or a larger `limit`."
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def list_alarms(include_archived: bool = False) -> dict:
|
|
|
|
|
"""UniFi's alarms, newest first: those still active, and with
|
|
|
|
|
include_archived the last 20 that were archived (dealt with in UniFi)."""
|
|
|
|
|
async with db_session() as db:
|
|
|
|
|
active = (await db.execute(select(UnifiAlarm).where(UnifiAlarm.archived.is_(False))
|
|
|
|
|
.order_by(UnifiAlarm.alarm_time.desc()))).scalars().all()
|
|
|
|
|
archived = ((await db.execute(select(UnifiAlarm).where(UnifiAlarm.archived.is_(True))
|
|
|
|
|
.order_by(UnifiAlarm.alarm_time.desc()).limit(20))).scalars().all()
|
|
|
|
|
if include_archived else [])
|
|
|
|
|
|
|
|
|
|
def row(a):
|
|
|
|
|
return {"at": iso(a.alarm_time), "key": a.alarm_key, "message": a.message}
|
|
|
|
|
|
|
|
|
|
out = {"active": [row(a) for a in active]}
|
|
|
|
|
if include_archived:
|
|
|
|
|
out["archived"] = [row(a) for a in archived]
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_network_config() -> dict:
|
|
|
|
|
"""How the UniFi network is set up, as the controller last reported it:
|
|
|
|
|
networks and VLANs (subnet, DHCP, guest), port forwards (what is open
|
|
|
|
|
from the internet, to which internal address), and firewall rules by
|
|
|
|
|
ruleset in order. Read-only: Steward never changes UniFi."""
|
|
|
|
|
async with db_session() as db:
|
|
|
|
|
networks = (await db.execute(select(UnifiNetwork).order_by(UnifiNetwork.name))).scalars().all()
|
|
|
|
|
forwards = (await db.execute(select(UnifiPortForward).order_by(UnifiPortForward.name))).scalars().all()
|
|
|
|
|
rules = (await db.execute(select(UnifiFwRule)
|
|
|
|
|
.order_by(UnifiFwRule.ruleset, UnifiFwRule.rule_index))).scalars().all()
|
|
|
|
|
return {
|
|
|
|
|
"networks": [{"name": n.name, "purpose": n.purpose, "vlan": n.vlan, "subnet": n.ip_subnet,
|
|
|
|
|
"dhcp": n.dhcp_enabled, "guest": n.is_guest} for n in networks],
|
|
|
|
|
"port_forwards": [{"name": f.name, "enabled": f.enabled, "proto": f.proto, "port": f.dst_port,
|
|
|
|
|
"to": f"{f.fwd_ip}:{f.fwd_port}", "from": f.src_filter or "any"} for f in forwards],
|
|
|
|
|
"firewall_rules": [{"ruleset": r.ruleset, "index": r.rule_index, "name": r.name, "action": r.action,
|
|
|
|
|
"protocol": r.protocol, "enabled": r.enabled, "src": r.src_address,
|
|
|
|
|
"dst": r.dst_address} for r in rules],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── get_overview ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def network_findings(wan, devices, alarms, now, fresh: timedelta) -> list[dict]:
|
|
|
|
|
"""The WAN down (critical, while the poll is current), devices not
|
|
|
|
|
working (offline critical, degraded warning; a transition such as an
|
|
|
|
|
upgrade is not a problem; a device the controller no longer reports is
|
|
|
|
|
left out), and active alarms (one warning naming the newest)."""
|
|
|
|
|
out = []
|
|
|
|
|
if wan is not None and not wan.is_up and now - wan.scraped_at <= fresh:
|
|
|
|
|
out.append(finding("critical", "unifi_wan_down", "WAN", "UniFi reports the WAN down",
|
|
|
|
|
since=wan.scraped_at, next="get_network()"))
|
|
|
|
|
for d in devices:
|
|
|
|
|
status, label = device_state(d.state)
|
|
|
|
|
if status not in ("down", "degraded") or now - d.scraped_at > fresh:
|
|
|
|
|
continue
|
|
|
|
|
name = d.name or d.mac
|
|
|
|
|
out.append(finding("critical" if status == "down" else "warning", "unifi_device_" + status, name,
|
|
|
|
|
f"UniFi {d.device_type or 'device'} {name} ({d.model or 'unknown model'}): {label}",
|
|
|
|
|
since=d.last_seen, ids={"mac": d.mac}, next='list_devices(status="%s")' % status))
|
|
|
|
|
if alarms:
|
|
|
|
|
newest = alarms[0]
|
|
|
|
|
more = f" and {len(alarms) - 1} more" if len(alarms) > 1 else ""
|
|
|
|
|
out.append(finding("warning", "unifi_alarms", "UniFi alarms",
|
|
|
|
|
f"UniFi alarm: {newest.message}{more}", since=newest.alarm_time,
|
|
|
|
|
next="list_alarms()"))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def mcp_overview(db) -> list[dict]:
|
|
|
|
|
now = now_utc()
|
|
|
|
|
wan = (await db.execute(select(UnifiWanStat).order_by(UnifiWanStat.scraped_at.desc()).limit(1))).scalar_one_or_none()
|
|
|
|
|
devices = (await db.execute(select(UnifiDevice))).scalars().all()
|
|
|
|
|
alarms = (await db.execute(select(UnifiAlarm).where(UnifiAlarm.archived.is_(False))
|
|
|
|
|
.order_by(UnifiAlarm.alarm_time.desc()))).scalars().all()
|
|
|
|
|
return network_findings(wan, devices, alarms, now, timedelta(seconds=FRESH_POLLS * _poll_seconds()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_tools(register) -> None:
|
|
|
|
|
for fn in (get_network, list_devices, list_clients, list_network_events, list_alarms,
|
|
|
|
|
get_network_config):
|
|
|
|
|
register(fn, role=UserRole.viewer, read_only=True)
|