argus-netbox 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- argus/__init__.py +3 -0
- argus/config.py +68 -0
- argus/confirmations.py +79 -0
- argus/discovery/__init__.py +17 -0
- argus/discovery/base.py +71 -0
- argus/discovery/collectors/__init__.py +16 -0
- argus/discovery/collectors/dhcp_arp.py +19 -0
- argus/discovery/collectors/snmp_lldp.py +112 -0
- argus/discovery/collectors/unifi.py +158 -0
- argus/http_server.py +193 -0
- argus/netbox/__init__.py +5 -0
- argus/netbox/client.py +200 -0
- argus/reconcile/__init__.py +5 -0
- argus/reconcile/engine.py +302 -0
- argus/scheduler.py +163 -0
- argus/server.py +54 -0
- argus/tools/__init__.py +1 -0
- argus/tools/discovery_tools.py +54 -0
- argus/tools/read_tools.py +81 -0
- argus/tools/reconcile_tools.py +118 -0
- argus/webhooks.py +90 -0
- argus_netbox-0.1.2.dist-info/METADATA +77 -0
- argus_netbox-0.1.2.dist-info/RECORD +26 -0
- argus_netbox-0.1.2.dist-info/WHEEL +5 -0
- argus_netbox-0.1.2.dist-info/entry_points.txt +3 -0
- argus_netbox-0.1.2.dist-info/top_level.txt +1 -0
argus/__init__.py
ADDED
argus/config.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Application settings, resolved from environment variables (and an optional .env)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Settings(BaseSettings):
|
|
11
|
+
"""Argus runtime configuration.
|
|
12
|
+
|
|
13
|
+
Environment variables are matched case-insensitively, so ``NETBOX_URL`` maps to
|
|
14
|
+
:attr:`netbox_url`. A local ``.env`` file is read if present.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
18
|
+
|
|
19
|
+
# NetBox (the source of truth)
|
|
20
|
+
netbox_url: str = ""
|
|
21
|
+
netbox_token: str = ""
|
|
22
|
+
netbox_verify_ssl: bool = True
|
|
23
|
+
|
|
24
|
+
# UniFi Network controller (discovery collector — Integration API, X-API-KEY)
|
|
25
|
+
unifi_url: str = ""
|
|
26
|
+
unifi_api_token: str = ""
|
|
27
|
+
unifi_site: str = "default"
|
|
28
|
+
unifi_verify_ssl: bool = False # UniFi controllers use self-signed certs
|
|
29
|
+
|
|
30
|
+
# SNMP/LLDP collector (generic, for non-UniFi gear). Comma-separated host[:community].
|
|
31
|
+
snmp_targets: str = ""
|
|
32
|
+
snmp_community: str = "public"
|
|
33
|
+
|
|
34
|
+
# FastAPI HTTP server
|
|
35
|
+
http_host: str = "0.0.0.0"
|
|
36
|
+
http_port: int = 8080
|
|
37
|
+
http_token: str = "" # optional static bearer token; unset disables auth
|
|
38
|
+
|
|
39
|
+
# Scheduled discovery + drift alerting (in-process asyncio loop; opt-in)
|
|
40
|
+
schedule_interval: int = 0 # seconds between drift cycles; 0 disables (e.g. 300 = 5 min)
|
|
41
|
+
schedule_collector: str = "unifi" # collector the scheduled drift cycle observes
|
|
42
|
+
alert_webhook_url: str = "" # Slack-compatible webhook; alert fires only on drift when set
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def http_auth_enabled(self) -> bool:
|
|
46
|
+
"""True when a static bearer token is configured for the HTTP API."""
|
|
47
|
+
return bool(self.http_token)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def schedule_enabled(self) -> bool:
|
|
51
|
+
"""True when the scheduled drift loop is enabled (a positive interval is set)."""
|
|
52
|
+
return self.schedule_interval > 0
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def netbox_configured(self) -> bool:
|
|
56
|
+
"""True when both a NetBox URL and token are set."""
|
|
57
|
+
return bool(self.netbox_url and self.netbox_token)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def unifi_configured(self) -> bool:
|
|
61
|
+
"""True when both a UniFi URL and API token are set."""
|
|
62
|
+
return bool(self.unifi_url and self.unifi_api_token)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@lru_cache
|
|
66
|
+
def get_settings() -> Settings:
|
|
67
|
+
"""Return the cached settings instance."""
|
|
68
|
+
return Settings()
|
argus/confirmations.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Confirmation store gating state-changing actions (e.g. reconcile apply).
|
|
2
|
+
|
|
3
|
+
A tool returns a confirmation token instead of acting; a second, explicit call with that
|
|
4
|
+
token performs the action. Tokens expire after a short TTL. This keeps an agent from
|
|
5
|
+
silently mutating the source of truth.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import secrets
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import UTC, datetime, timedelta
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PendingAction:
|
|
18
|
+
"""An action awaiting explicit confirmation."""
|
|
19
|
+
|
|
20
|
+
action_id: str
|
|
21
|
+
tool_name: str
|
|
22
|
+
description: str
|
|
23
|
+
created_at: datetime
|
|
24
|
+
expires_at: datetime
|
|
25
|
+
tool_args: dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfirmationStore:
|
|
29
|
+
"""In-memory store of pending, confirmable actions."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, ttl_minutes: int = 5) -> None:
|
|
32
|
+
self._actions: dict[str, PendingAction] = {}
|
|
33
|
+
self.ttl = timedelta(minutes=ttl_minutes)
|
|
34
|
+
|
|
35
|
+
def create(
|
|
36
|
+
self, tool_name: str, description: str, tool_args: dict[str, Any] | None = None
|
|
37
|
+
) -> PendingAction:
|
|
38
|
+
"""Register a pending action and return it (with its ``action_id``)."""
|
|
39
|
+
self.cleanup_expired()
|
|
40
|
+
now = datetime.now(UTC)
|
|
41
|
+
action = PendingAction(
|
|
42
|
+
action_id=secrets.token_urlsafe(16),
|
|
43
|
+
tool_name=tool_name,
|
|
44
|
+
description=description,
|
|
45
|
+
created_at=now,
|
|
46
|
+
expires_at=now + self.ttl,
|
|
47
|
+
tool_args=tool_args or {},
|
|
48
|
+
)
|
|
49
|
+
self._actions[action.action_id] = action
|
|
50
|
+
return action
|
|
51
|
+
|
|
52
|
+
def get(self, action_id: str) -> PendingAction | None:
|
|
53
|
+
"""Return a pending action, or ``None`` if missing or expired."""
|
|
54
|
+
action = self._actions.get(action_id)
|
|
55
|
+
if action and datetime.now(UTC) > action.expires_at:
|
|
56
|
+
del self._actions[action_id]
|
|
57
|
+
return None
|
|
58
|
+
return action
|
|
59
|
+
|
|
60
|
+
def confirm(self, action_id: str) -> tuple[PendingAction | None, str | None]:
|
|
61
|
+
"""Consume and return an action. Returns ``(action, error)``."""
|
|
62
|
+
action = self.get(action_id)
|
|
63
|
+
if not action:
|
|
64
|
+
return None, "Action expired or not found."
|
|
65
|
+
del self._actions[action_id]
|
|
66
|
+
return action, None
|
|
67
|
+
|
|
68
|
+
def cleanup_expired(self) -> int:
|
|
69
|
+
"""Drop expired actions; return how many were removed."""
|
|
70
|
+
now = datetime.now(UTC)
|
|
71
|
+
expired = [aid for aid, a in self._actions.items() if a.expires_at < now]
|
|
72
|
+
for aid in expired:
|
|
73
|
+
del self._actions[aid]
|
|
74
|
+
return len(expired)
|
|
75
|
+
|
|
76
|
+
def list_pending(self) -> list[PendingAction]:
|
|
77
|
+
"""Return all currently pending (non-expired) actions."""
|
|
78
|
+
self.cleanup_expired()
|
|
79
|
+
return list(self._actions.values())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Discovery layer: pluggable collectors that observe live network state."""
|
|
2
|
+
|
|
3
|
+
from .base import (
|
|
4
|
+
Collector,
|
|
5
|
+
DiscoveredClient,
|
|
6
|
+
DiscoveredDevice,
|
|
7
|
+
DiscoveredLink,
|
|
8
|
+
DiscoveryResult,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Collector",
|
|
13
|
+
"DiscoveredClient",
|
|
14
|
+
"DiscoveredDevice",
|
|
15
|
+
"DiscoveredLink",
|
|
16
|
+
"DiscoveryResult",
|
|
17
|
+
]
|
argus/discovery/base.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Discovery interfaces.
|
|
2
|
+
|
|
3
|
+
A :class:`Collector` observes live network state from one source and returns a
|
|
4
|
+
normalized :class:`DiscoveryResult`. Collectors are read-only against the network — the
|
|
5
|
+
only writes Argus makes are into NetBox, via the reconcile engine (see ADR-0003).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DiscoveredDevice:
|
|
17
|
+
"""A device observed on the live network, normalized across collectors."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
mac: str | None = None
|
|
21
|
+
primary_ip: str | None = None
|
|
22
|
+
site: str | None = None
|
|
23
|
+
role: str | None = None
|
|
24
|
+
model: str | None = None
|
|
25
|
+
manufacturer: str | None = None
|
|
26
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class DiscoveredClient:
|
|
31
|
+
"""An endpoint/client observed on the network (the IP/MAC-binding side)."""
|
|
32
|
+
|
|
33
|
+
mac: str | None = None
|
|
34
|
+
ip: str | None = None
|
|
35
|
+
hostname: str | None = None
|
|
36
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class DiscoveredLink:
|
|
41
|
+
"""A directed link between two devices (e.g. a device and its uplink)."""
|
|
42
|
+
|
|
43
|
+
local_device: str
|
|
44
|
+
remote_device: str
|
|
45
|
+
local_port: str | None = None
|
|
46
|
+
remote_port: str | None = None
|
|
47
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class DiscoveryResult:
|
|
52
|
+
"""The normalized output of a single collector run."""
|
|
53
|
+
|
|
54
|
+
collector: str
|
|
55
|
+
devices: list[DiscoveredDevice] = field(default_factory=list)
|
|
56
|
+
clients: list[DiscoveredClient] = field(default_factory=list)
|
|
57
|
+
links: list[DiscoveredLink] = field(default_factory=list)
|
|
58
|
+
ip_addresses: list[str] = field(default_factory=list)
|
|
59
|
+
notes: list[str] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Collector(ABC):
|
|
63
|
+
"""Observes live network state from one source."""
|
|
64
|
+
|
|
65
|
+
#: Stable, unique collector name (used in the registry and as a tool argument).
|
|
66
|
+
name: str = "base"
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
async def collect(self) -> DiscoveryResult:
|
|
70
|
+
"""Collect current state from this source."""
|
|
71
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Discovery collector registry — name → collector class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..base import Collector
|
|
6
|
+
from .dhcp_arp import DhcpArpCollector
|
|
7
|
+
from .snmp_lldp import SnmpLldpCollector
|
|
8
|
+
from .unifi import UniFiCollector
|
|
9
|
+
|
|
10
|
+
COLLECTORS: dict[str, type[Collector]] = {
|
|
11
|
+
UniFiCollector.name: UniFiCollector,
|
|
12
|
+
SnmpLldpCollector.name: SnmpLldpCollector,
|
|
13
|
+
DhcpArpCollector.name: DhcpArpCollector,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
__all__ = ["COLLECTORS", "DhcpArpCollector", "SnmpLldpCollector", "UniFiCollector"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""DHCP / ARP collector (stub).
|
|
2
|
+
|
|
3
|
+
Planned P1: read DHCP leases and ARP tables to learn IP/MAC bindings.
|
|
4
|
+
See docs/ROADMAP.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ..base import Collector, DiscoveryResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DhcpArpCollector(Collector):
|
|
13
|
+
name = "dhcp_arp"
|
|
14
|
+
|
|
15
|
+
async def collect(self) -> DiscoveryResult:
|
|
16
|
+
return DiscoveryResult(
|
|
17
|
+
collector=self.name,
|
|
18
|
+
notes=["DHCP/ARP collector not yet implemented (planned P1)."],
|
|
19
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""SNMP / LLDP discovery collector (generic, for non-UniFi gear).
|
|
2
|
+
|
|
3
|
+
Per target: SNMP GET ``sysName`` + an LLDP-MIB neighbor walk for links. Configured via
|
|
4
|
+
``SNMP_TARGETS`` (comma-separated ``host[:community]``) and ``SNMP_COMMUNITY``. Requires the
|
|
5
|
+
optional ``discovery`` extra: ``pip install 'argus[discovery]'`` (pysnmp).
|
|
6
|
+
|
|
7
|
+
NOTE: the pysnmp glue (`_query_target`) is best-effort and **unvalidated against live SNMP
|
|
8
|
+
devices**; the collector logic (config parsing, mapping, links) is unit-tested by mocking it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
from ...config import get_settings
|
|
16
|
+
from ..base import Collector, DiscoveredDevice, DiscoveredLink, DiscoveryResult
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
SYSNAME_OID = "1.3.6.1.2.1.1.5.0"
|
|
21
|
+
LLDP_REM_SYSNAME_OID = "1.0.8802.1.1.2.1.4.1.1.9"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_targets(raw: str, default_community: str) -> list[tuple[str, str]]:
|
|
25
|
+
"""Parse ``host[:community],host2,...`` into (host, community) pairs."""
|
|
26
|
+
targets: list[tuple[str, str]] = []
|
|
27
|
+
for item in raw.split(","):
|
|
28
|
+
item = item.strip()
|
|
29
|
+
if not item:
|
|
30
|
+
continue
|
|
31
|
+
host, _, community = item.partition(":")
|
|
32
|
+
targets.append((host.strip(), community.strip() or default_community))
|
|
33
|
+
return targets
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _query_target(host: str, community: str) -> tuple[str | None, list[str]]:
|
|
37
|
+
"""Return ``(sysName, [neighbor sysNames])`` for a target. Raises ImportError if pysnmp
|
|
38
|
+
is absent; other failures are caught by the caller."""
|
|
39
|
+
from pysnmp.hlapi.asyncio import (
|
|
40
|
+
CommunityData,
|
|
41
|
+
ContextData,
|
|
42
|
+
ObjectIdentity,
|
|
43
|
+
ObjectType,
|
|
44
|
+
SnmpEngine,
|
|
45
|
+
UdpTransportTarget,
|
|
46
|
+
get_cmd,
|
|
47
|
+
walk_cmd,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
engine = SnmpEngine()
|
|
51
|
+
auth = CommunityData(community, mpModel=1)
|
|
52
|
+
transport = await UdpTransportTarget.create((host, 161))
|
|
53
|
+
|
|
54
|
+
err_ind, err_stat, _, var_binds = await get_cmd(
|
|
55
|
+
engine, auth, transport, ContextData(), ObjectType(ObjectIdentity(SYSNAME_OID))
|
|
56
|
+
)
|
|
57
|
+
if err_ind or err_stat or not var_binds:
|
|
58
|
+
return None, []
|
|
59
|
+
sysname = str(var_binds[0][1])
|
|
60
|
+
|
|
61
|
+
neighbors: list[str] = []
|
|
62
|
+
try:
|
|
63
|
+
async for w_err_ind, w_err_stat, _, w_binds in walk_cmd(
|
|
64
|
+
engine, auth, transport, ContextData(), ObjectType(ObjectIdentity(LLDP_REM_SYSNAME_OID))
|
|
65
|
+
):
|
|
66
|
+
if w_err_ind or w_err_stat:
|
|
67
|
+
break
|
|
68
|
+
for _, value in w_binds:
|
|
69
|
+
name = str(value).strip()
|
|
70
|
+
if name:
|
|
71
|
+
neighbors.append(name)
|
|
72
|
+
except Exception as exc: # LLDP is optional; keep the device even if the walk fails
|
|
73
|
+
logger.debug("LLDP walk failed for %s: %s", host, exc)
|
|
74
|
+
|
|
75
|
+
return sysname, neighbors
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SnmpLldpCollector(Collector):
|
|
79
|
+
name = "snmp_lldp"
|
|
80
|
+
|
|
81
|
+
async def collect(self) -> DiscoveryResult:
|
|
82
|
+
settings = get_settings()
|
|
83
|
+
result = DiscoveryResult(collector=self.name)
|
|
84
|
+
targets = _parse_targets(settings.snmp_targets, settings.snmp_community)
|
|
85
|
+
|
|
86
|
+
if not targets:
|
|
87
|
+
result.notes.append("SNMP not configured: set SNMP_TARGETS (host[:community],...).")
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
for host, community in targets:
|
|
91
|
+
try:
|
|
92
|
+
sysname, neighbors = await _query_target(host, community)
|
|
93
|
+
except ImportError:
|
|
94
|
+
result.notes.append("pysnmp not installed: pip install 'argus[discovery]'.")
|
|
95
|
+
return result
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
result.notes.append(f"SNMP query failed for {host}: {exc}")
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if not sysname:
|
|
101
|
+
result.notes.append(f"No SNMP response from {host}.")
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
result.devices.append(DiscoveredDevice(name=sysname, primary_ip=host))
|
|
105
|
+
for neighbor in neighbors:
|
|
106
|
+
result.links.append(DiscoveredLink(local_device=sysname, remote_device=neighbor))
|
|
107
|
+
|
|
108
|
+
result.notes.append(
|
|
109
|
+
f"SNMP: {len(result.devices)} device(s), {len(result.links)} link(s) "
|
|
110
|
+
f"from {len(targets)} target(s)."
|
|
111
|
+
)
|
|
112
|
+
return result
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""UniFi discovery collector.
|
|
2
|
+
|
|
3
|
+
Pulls devices from the UniFi Network **Integration API** (X-API-KEY auth, read-only),
|
|
4
|
+
mirroring the approach in the sibling ``aria-unifi-mcp`` server, and normalizes them into
|
|
5
|
+
a :class:`DiscoveryResult`. Requires ``UNIFI_URL`` + ``UNIFI_API_TOKEN`` (see config).
|
|
6
|
+
|
|
7
|
+
Endpoints used (base ``{UNIFI_URL}/proxy/network/integration/v1``):
|
|
8
|
+
- ``GET /sites`` → ``{"data": [{id, internalReference, name}]}``
|
|
9
|
+
- ``GET /sites/{site_id}/devices`` → ``{"data": [{name, mac, model, state, ipAddress, ...}]}``
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ...config import get_settings
|
|
19
|
+
from ..base import (
|
|
20
|
+
Collector,
|
|
21
|
+
DiscoveredClient,
|
|
22
|
+
DiscoveredDevice,
|
|
23
|
+
DiscoveredLink,
|
|
24
|
+
DiscoveryResult,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Best-effort NetBox device-role inference from the UniFi model string. The Integration
|
|
28
|
+
# API returns full model names ("UniFi Dream Machine PRO SE", "USW Pro 48 PoE", "U6 Pro"),
|
|
29
|
+
# so match on keywords rather than code prefixes. Order matters — gateway is checked first.
|
|
30
|
+
_ROLE_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
31
|
+
("gateway", ("dream machine", "udm", "uxg", "ucg", "ugw", "cloud gateway", "security gateway", "gateway")),
|
|
32
|
+
("switch", ("usw", "switch", "aggregation", "us-")),
|
|
33
|
+
("ap", ("u6", "u7", "uap", "access point", "nanohd", "ac lite", "ac pro", "ac mesh")),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _role_from_model(model: str | None) -> str | None:
|
|
38
|
+
if not model:
|
|
39
|
+
return None
|
|
40
|
+
text = model.lower()
|
|
41
|
+
for role, keywords in _ROLE_KEYWORDS:
|
|
42
|
+
if any(keyword in text for keyword in keywords):
|
|
43
|
+
return role
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _pick_site(sites: list[dict[str, Any]], reference: str) -> dict[str, Any] | None:
|
|
48
|
+
for site in sites:
|
|
49
|
+
if site.get("internalReference") == reference:
|
|
50
|
+
return site
|
|
51
|
+
return sites[0] if sites else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _get(client: httpx.AsyncClient, url: str) -> dict[str, Any]:
|
|
55
|
+
response = await client.get(url)
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
data: dict[str, Any] = response.json()
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UniFiCollector(Collector):
|
|
62
|
+
name = "unifi"
|
|
63
|
+
|
|
64
|
+
async def collect(self) -> DiscoveryResult:
|
|
65
|
+
settings = get_settings()
|
|
66
|
+
result = DiscoveryResult(collector=self.name)
|
|
67
|
+
|
|
68
|
+
if not settings.unifi_configured:
|
|
69
|
+
result.notes.append("UniFi not configured: set UNIFI_URL and UNIFI_API_TOKEN.")
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
base = settings.unifi_url.rstrip("/") + "/proxy/network/integration/v1"
|
|
73
|
+
headers = {"X-API-KEY": settings.unifi_api_token, "Accept": "application/json"}
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
async with httpx.AsyncClient(
|
|
77
|
+
headers=headers, verify=settings.unifi_verify_ssl, timeout=30.0
|
|
78
|
+
) as client:
|
|
79
|
+
sites = (await _get(client, f"{base}/sites")).get("data", [])
|
|
80
|
+
site = _pick_site(sites, settings.unifi_site)
|
|
81
|
+
if site is None:
|
|
82
|
+
result.notes.append("No UniFi sites returned by the controller.")
|
|
83
|
+
return result
|
|
84
|
+
devices = (await _get(client, f"{base}/sites/{site['id']}/devices")).get(
|
|
85
|
+
"data", []
|
|
86
|
+
)
|
|
87
|
+
# Clients are best-effort: a controller without the endpoint still yields devices.
|
|
88
|
+
try:
|
|
89
|
+
clients = (
|
|
90
|
+
await _get(client, f"{base}/sites/{site['id']}/clients?limit=200")
|
|
91
|
+
).get("data", [])
|
|
92
|
+
except httpx.HTTPError as exc:
|
|
93
|
+
clients = []
|
|
94
|
+
result.notes.append(f"UniFi clients endpoint unavailable: {exc}")
|
|
95
|
+
# Topology (best-effort): each device's detail carries its uplink device id.
|
|
96
|
+
uplinks: dict[str, str] = {}
|
|
97
|
+
for device in devices:
|
|
98
|
+
did = device.get("id")
|
|
99
|
+
if not did:
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
detail = await _get(client, f"{base}/sites/{site['id']}/devices/{did}")
|
|
103
|
+
except httpx.HTTPError:
|
|
104
|
+
continue
|
|
105
|
+
remote = (detail.get("uplink") or {}).get("deviceId")
|
|
106
|
+
if remote:
|
|
107
|
+
uplinks[did] = remote
|
|
108
|
+
except httpx.HTTPError as exc:
|
|
109
|
+
result.notes.append(f"UniFi API request failed: {exc}")
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
site_name = site.get("name") or site.get("internalReference")
|
|
113
|
+
for device in devices:
|
|
114
|
+
ip = device.get("ipAddress") or device.get("ip")
|
|
115
|
+
result.devices.append(
|
|
116
|
+
DiscoveredDevice(
|
|
117
|
+
name=device.get("name") or device.get("mac") or "unknown",
|
|
118
|
+
mac=device.get("mac"),
|
|
119
|
+
primary_ip=ip,
|
|
120
|
+
site=site_name,
|
|
121
|
+
role=_role_from_model(device.get("model")),
|
|
122
|
+
model=device.get("model"),
|
|
123
|
+
manufacturer="Ubiquiti",
|
|
124
|
+
raw=device,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
if ip:
|
|
128
|
+
result.ip_addresses.append(ip)
|
|
129
|
+
|
|
130
|
+
for entry in clients:
|
|
131
|
+
ip = entry.get("ipAddress") or entry.get("ip")
|
|
132
|
+
result.clients.append(
|
|
133
|
+
DiscoveredClient(
|
|
134
|
+
mac=entry.get("macAddress") or entry.get("mac"),
|
|
135
|
+
ip=ip,
|
|
136
|
+
hostname=entry.get("name") or entry.get("hostname"),
|
|
137
|
+
raw=entry,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
if ip:
|
|
141
|
+
result.ip_addresses.append(ip)
|
|
142
|
+
|
|
143
|
+
id_to_name = {
|
|
144
|
+
d.get("id"): (d.get("name") or d.get("macAddress") or d.get("id"))
|
|
145
|
+
for d in devices
|
|
146
|
+
if d.get("id")
|
|
147
|
+
}
|
|
148
|
+
for local_id, remote_id in uplinks.items():
|
|
149
|
+
local = id_to_name.get(local_id)
|
|
150
|
+
remote = id_to_name.get(remote_id)
|
|
151
|
+
if local and remote:
|
|
152
|
+
result.links.append(DiscoveredLink(local_device=local, remote_device=remote))
|
|
153
|
+
|
|
154
|
+
result.notes.append(
|
|
155
|
+
f"Discovered {len(result.devices)} device(s), {len(result.clients)} client(s), "
|
|
156
|
+
f"{len(result.links)} link(s) from UniFi site '{site_name}'."
|
|
157
|
+
)
|
|
158
|
+
return result
|