argus-netbox 0.1.2__tar.gz
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_netbox-0.1.2/PKG-INFO +77 -0
- argus_netbox-0.1.2/README.md +50 -0
- argus_netbox-0.1.2/pyproject.toml +65 -0
- argus_netbox-0.1.2/setup.cfg +4 -0
- argus_netbox-0.1.2/src/argus/__init__.py +3 -0
- argus_netbox-0.1.2/src/argus/config.py +68 -0
- argus_netbox-0.1.2/src/argus/confirmations.py +79 -0
- argus_netbox-0.1.2/src/argus/discovery/__init__.py +17 -0
- argus_netbox-0.1.2/src/argus/discovery/base.py +71 -0
- argus_netbox-0.1.2/src/argus/discovery/collectors/__init__.py +16 -0
- argus_netbox-0.1.2/src/argus/discovery/collectors/dhcp_arp.py +19 -0
- argus_netbox-0.1.2/src/argus/discovery/collectors/snmp_lldp.py +112 -0
- argus_netbox-0.1.2/src/argus/discovery/collectors/unifi.py +158 -0
- argus_netbox-0.1.2/src/argus/http_server.py +193 -0
- argus_netbox-0.1.2/src/argus/netbox/__init__.py +5 -0
- argus_netbox-0.1.2/src/argus/netbox/client.py +200 -0
- argus_netbox-0.1.2/src/argus/reconcile/__init__.py +5 -0
- argus_netbox-0.1.2/src/argus/reconcile/engine.py +302 -0
- argus_netbox-0.1.2/src/argus/scheduler.py +163 -0
- argus_netbox-0.1.2/src/argus/server.py +54 -0
- argus_netbox-0.1.2/src/argus/tools/__init__.py +1 -0
- argus_netbox-0.1.2/src/argus/tools/discovery_tools.py +54 -0
- argus_netbox-0.1.2/src/argus/tools/read_tools.py +81 -0
- argus_netbox-0.1.2/src/argus/tools/reconcile_tools.py +118 -0
- argus_netbox-0.1.2/src/argus/webhooks.py +90 -0
- argus_netbox-0.1.2/src/argus_netbox.egg-info/PKG-INFO +77 -0
- argus_netbox-0.1.2/src/argus_netbox.egg-info/SOURCES.txt +38 -0
- argus_netbox-0.1.2/src/argus_netbox.egg-info/dependency_links.txt +1 -0
- argus_netbox-0.1.2/src/argus_netbox.egg-info/entry_points.txt +3 -0
- argus_netbox-0.1.2/src/argus_netbox.egg-info/requires.txt +20 -0
- argus_netbox-0.1.2/src/argus_netbox.egg-info/top_level.txt +1 -0
- argus_netbox-0.1.2/tests/test_config.py +23 -0
- argus_netbox-0.1.2/tests/test_http_server.py +91 -0
- argus_netbox-0.1.2/tests/test_netbox_client.py +91 -0
- argus_netbox-0.1.2/tests/test_reconcile.py +274 -0
- argus_netbox-0.1.2/tests/test_scheduler.py +179 -0
- argus_netbox-0.1.2/tests/test_snmp_collector.py +65 -0
- argus_netbox-0.1.2/tests/test_tools.py +79 -0
- argus_netbox-0.1.2/tests/test_unifi_collector.py +177 -0
- argus_netbox-0.1.2/tests/test_webhooks.py +122 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: argus-netbox
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: NetBox-backed network source-of-truth automation server for MCP coding agents
|
|
5
|
+
Author: Jon Freed
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: mcp>=1.28.0
|
|
10
|
+
Requires-Dist: pynetbox>=7.3
|
|
11
|
+
Requires-Dist: httpx>=0.28.1
|
|
12
|
+
Requires-Dist: fastapi>=0.137.1
|
|
13
|
+
Requires-Dist: uvicorn>=0.34
|
|
14
|
+
Requires-Dist: pydantic>=2.13.4
|
|
15
|
+
Requires-Dist: pydantic-settings>=2.3
|
|
16
|
+
Provides-Extra: discovery
|
|
17
|
+
Requires-Dist: napalm>=5.1.0; extra == "discovery"
|
|
18
|
+
Requires-Dist: netmiko>=4.3; extra == "discovery"
|
|
19
|
+
Requires-Dist: pysnmp>=7.1.27; extra == "discovery"
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-asyncio>=1.4.0; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov>=7.1.0; extra == "dev"
|
|
24
|
+
Requires-Dist: respx>=0.23.1; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff>=0.15.17; extra == "dev"
|
|
26
|
+
Requires-Dist: mypy>=2.1.0; extra == "dev"
|
|
27
|
+
|
|
28
|
+
# Argus server
|
|
29
|
+
|
|
30
|
+
Python MCP + FastAPI server. NetBox source-of-truth tools for coding agents and the
|
|
31
|
+
Argus web dashboard. See the [top-level README](../README.md) and
|
|
32
|
+
[docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md).
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
python -m venv .venv && source .venv/bin/activate
|
|
38
|
+
pip install -e ".[dev]"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configure
|
|
42
|
+
|
|
43
|
+
Copy `.env.example` to `.env` (or export the vars):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
NETBOX_URL=https://netbox.lan
|
|
47
|
+
NETBOX_TOKEN=<netbox api token>
|
|
48
|
+
NETBOX_VERIFY_SSL=true
|
|
49
|
+
HTTP_HOST=0.0.0.0
|
|
50
|
+
HTTP_PORT=8080
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If unset, tools return a clear "NetBox not configured" message instead of erroring.
|
|
54
|
+
|
|
55
|
+
## Run
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
argus-mcp # MCP server over stdio (for Claude Code etc.)
|
|
59
|
+
argus-http # FastAPI HTTP server on :8080 (for the web app + webhooks)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Develop
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
ruff check src tests
|
|
66
|
+
mypy src
|
|
67
|
+
pytest -v # offline — NetBox is mocked
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Tools
|
|
71
|
+
|
|
72
|
+
| Tool | Kind | Status |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `list_devices`, `get_device`, `list_prefixes`, `list_ip_addresses`, `search` | read | real (needs NetBox) |
|
|
75
|
+
| `list_collectors`, `discovery_scan`, `network_topology` | discovery | UniFi real — devices + clients + uplink topology (needs `UNIFI_*`); SNMP/LLDP real for non-UniFi gear (needs `SNMP_TARGETS` + `argus[discovery]`) |
|
|
76
|
+
| `drift_report`, `reconcile_apply` | reconcile | real — diffs and (on confirm) persists, auto-creating supporting NetBox objects |
|
|
77
|
+
| `health` | meta | real |
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Argus server
|
|
2
|
+
|
|
3
|
+
Python MCP + FastAPI server. NetBox source-of-truth tools for coding agents and the
|
|
4
|
+
Argus web dashboard. See the [top-level README](../README.md) and
|
|
5
|
+
[docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python -m venv .venv && source .venv/bin/activate
|
|
11
|
+
pip install -e ".[dev]"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Configure
|
|
15
|
+
|
|
16
|
+
Copy `.env.example` to `.env` (or export the vars):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
NETBOX_URL=https://netbox.lan
|
|
20
|
+
NETBOX_TOKEN=<netbox api token>
|
|
21
|
+
NETBOX_VERIFY_SSL=true
|
|
22
|
+
HTTP_HOST=0.0.0.0
|
|
23
|
+
HTTP_PORT=8080
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
If unset, tools return a clear "NetBox not configured" message instead of erroring.
|
|
27
|
+
|
|
28
|
+
## Run
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
argus-mcp # MCP server over stdio (for Claude Code etc.)
|
|
32
|
+
argus-http # FastAPI HTTP server on :8080 (for the web app + webhooks)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Develop
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
ruff check src tests
|
|
39
|
+
mypy src
|
|
40
|
+
pytest -v # offline — NetBox is mocked
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Tools
|
|
44
|
+
|
|
45
|
+
| Tool | Kind | Status |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| `list_devices`, `get_device`, `list_prefixes`, `list_ip_addresses`, `search` | read | real (needs NetBox) |
|
|
48
|
+
| `list_collectors`, `discovery_scan`, `network_topology` | discovery | UniFi real — devices + clients + uplink topology (needs `UNIFI_*`); SNMP/LLDP real for non-UniFi gear (needs `SNMP_TARGETS` + `argus[discovery]`) |
|
|
49
|
+
| `drift_report`, `reconcile_apply` | reconcile | real — diffs and (on confirm) persists, auto-creating supporting NetBox objects |
|
|
50
|
+
| `health` | meta | real |
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "argus-netbox"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "NetBox-backed network source-of-truth automation server for MCP coding agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [{ name = "Jon Freed" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"mcp>=1.28.0",
|
|
15
|
+
"pynetbox>=7.3",
|
|
16
|
+
"httpx>=0.28.1",
|
|
17
|
+
"fastapi>=0.137.1",
|
|
18
|
+
"uvicorn>=0.34",
|
|
19
|
+
"pydantic>=2.13.4",
|
|
20
|
+
"pydantic-settings>=2.3",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
# Heavy network-discovery libraries — declared for P1, not used by the scaffold yet.
|
|
25
|
+
discovery = [
|
|
26
|
+
"napalm>=5.1.0",
|
|
27
|
+
"netmiko>=4.3",
|
|
28
|
+
"pysnmp>=7.1.27",
|
|
29
|
+
]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest>=8.0",
|
|
32
|
+
"pytest-asyncio>=1.4.0",
|
|
33
|
+
"pytest-cov>=7.1.0",
|
|
34
|
+
"respx>=0.23.1",
|
|
35
|
+
"ruff>=0.15.17",
|
|
36
|
+
"mypy>=2.1.0",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
argus-mcp = "argus.server:main"
|
|
41
|
+
argus-http = "argus.http_server:main"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 100
|
|
48
|
+
target-version = "py312"
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
52
|
+
ignore = ["E501"]
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint.isort]
|
|
55
|
+
known-first-party = ["argus"]
|
|
56
|
+
|
|
57
|
+
[tool.mypy]
|
|
58
|
+
python_version = "3.12"
|
|
59
|
+
warn_return_any = true
|
|
60
|
+
warn_unused_configs = true
|
|
61
|
+
ignore_missing_imports = true
|
|
62
|
+
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
asyncio_mode = "auto"
|
|
65
|
+
testpaths = ["tests"]
|
|
@@ -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()
|
|
@@ -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
|