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 ADDED
@@ -0,0 +1,3 @@
1
+ """Argus — NetBox-backed network source-of-truth automation server."""
2
+
3
+ __version__ = "0.1.2"
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
+ ]
@@ -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